pilotswarm-sdk 0.3.2 → 0.3.3

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 (82) hide show
  1. package/dist/cms-migrations.d.ts.map +1 -1
  2. package/dist/cms-migrations.js +250 -0
  3. package/dist/cms-migrations.js.map +1 -1
  4. package/dist/cms.d.ts +10 -0
  5. package/dist/cms.d.ts.map +1 -1
  6. package/dist/cms.js +5 -0
  7. package/dist/cms.js.map +1 -1
  8. package/dist/managed-session.d.ts +35 -0
  9. package/dist/managed-session.d.ts.map +1 -1
  10. package/dist/managed-session.js +74 -0
  11. package/dist/managed-session.js.map +1 -1
  12. package/dist/management-client.d.ts +22 -1
  13. package/dist/management-client.d.ts.map +1 -1
  14. package/dist/management-client.js +65 -1
  15. package/dist/management-client.js.map +1 -1
  16. package/dist/orchestration/index.d.ts +2 -2
  17. package/dist/orchestration/index.js +1 -1
  18. package/dist/orchestration/runtime.d.ts +1 -1
  19. package/dist/orchestration/turn.d.ts +13 -0
  20. package/dist/orchestration/turn.d.ts.map +1 -1
  21. package/dist/orchestration/turn.js +317 -163
  22. package/dist/orchestration/turn.js.map +1 -1
  23. package/dist/orchestration-registry.d.ts.map +1 -1
  24. package/dist/orchestration-registry.js +4 -2
  25. package/dist/orchestration-registry.js.map +1 -1
  26. package/dist/orchestration-version.d.ts +1 -1
  27. package/dist/orchestration-version.js +1 -1
  28. package/dist/orchestration.d.ts +2 -2
  29. package/dist/orchestration.js +1 -1
  30. package/dist/orchestration_1_0_46.d.ts +1 -1
  31. package/dist/orchestration_1_0_47.d.ts +1 -1
  32. package/dist/orchestration_1_0_48.d.ts +1 -1
  33. package/dist/orchestration_1_0_49.d.ts +1 -1
  34. package/dist/orchestration_1_0_50.d.ts +1 -1
  35. package/dist/orchestration_1_0_55/agents.d.ts +41 -0
  36. package/dist/orchestration_1_0_55/agents.d.ts.map +1 -0
  37. package/dist/orchestration_1_0_55/agents.js +758 -0
  38. package/dist/orchestration_1_0_55/agents.js.map +1 -0
  39. package/dist/orchestration_1_0_55/index.d.ts +24 -0
  40. package/dist/orchestration_1_0_55/index.d.ts.map +1 -0
  41. package/dist/orchestration_1_0_55/index.js +13 -0
  42. package/dist/orchestration_1_0_55/index.js.map +1 -0
  43. package/dist/orchestration_1_0_55/lifecycle.d.ts +41 -0
  44. package/dist/orchestration_1_0_55/lifecycle.d.ts.map +1 -0
  45. package/dist/orchestration_1_0_55/lifecycle.js +580 -0
  46. package/dist/orchestration_1_0_55/lifecycle.js.map +1 -0
  47. package/dist/orchestration_1_0_55/queue.d.ts +7 -0
  48. package/dist/orchestration_1_0_55/queue.d.ts.map +1 -0
  49. package/dist/orchestration_1_0_55/queue.js +644 -0
  50. package/dist/orchestration_1_0_55/queue.js.map +1 -0
  51. package/dist/orchestration_1_0_55/runtime.d.ts +29 -0
  52. package/dist/orchestration_1_0_55/runtime.d.ts.map +1 -0
  53. package/dist/orchestration_1_0_55/runtime.js +194 -0
  54. package/dist/orchestration_1_0_55/runtime.js.map +1 -0
  55. package/dist/orchestration_1_0_55/state.d.ts +126 -0
  56. package/dist/orchestration_1_0_55/state.d.ts.map +1 -0
  57. package/dist/orchestration_1_0_55/state.js +106 -0
  58. package/dist/orchestration_1_0_55/state.js.map +1 -0
  59. package/dist/orchestration_1_0_55/turn.d.ts +17 -0
  60. package/dist/orchestration_1_0_55/turn.d.ts.map +1 -0
  61. package/dist/orchestration_1_0_55/turn.js +921 -0
  62. package/dist/orchestration_1_0_55/turn.js.map +1 -0
  63. package/dist/orchestration_1_0_55/utils.d.ts +22 -0
  64. package/dist/orchestration_1_0_55/utils.d.ts.map +1 -0
  65. package/dist/orchestration_1_0_55/utils.js +226 -0
  66. package/dist/orchestration_1_0_55/utils.js.map +1 -0
  67. package/dist/session-manager.d.ts +20 -1
  68. package/dist/session-manager.d.ts.map +1 -1
  69. package/dist/session-manager.js +62 -0
  70. package/dist/session-manager.js.map +1 -1
  71. package/dist/session-proxy.d.ts +7 -0
  72. package/dist/session-proxy.d.ts.map +1 -1
  73. package/dist/session-proxy.js +35 -1
  74. package/dist/session-proxy.js.map +1 -1
  75. package/dist/types.d.ts +32 -0
  76. package/dist/types.d.ts.map +1 -1
  77. package/dist/types.js +10 -0
  78. package/dist/types.js.map +1 -1
  79. package/dist/worker.d.ts.map +1 -1
  80. package/dist/worker.js +10 -0
  81. package/dist/worker.js.map +1 -1
  82. package/package.json +3 -3
@@ -1,8 +1,8 @@
1
- import { SESSION_STATE_MISSING_PREFIX } from "../types.js";
1
+ import { SESSION_STATE_MISSING_PREFIX, stopTurnQueueName } from "../types.js";
2
2
  import { createSessionProxy } from "../session-proxy.js";
3
3
  import { planWaitHandling } from "../wait-affinity.js";
4
4
  import { buildShutdownWaitReason, failPendingShutdown, getStillRunningAgentIds, handleSubAgentAction, isSubAgentTerminalStatus, maybeResolveAgentWaitCompletion, refreshTrackedSubAgents, } from "./agents.js";
5
- import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, dehydrateForNextTurn, drainLeadingQueuedScheduleActions, ensureTaskContext, maybeCheckpoint, maybeSummarize, publishStatus, versionedContinueAsNew, wrapWithResumeContext, writeLatestResponse, } from "./lifecycle.js";
5
+ import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, dehydrateForNextTurn, drainLeadingQueuedScheduleActions, ensureTaskContext, maybeCheckpoint, maybeSummarize, publishStatus, versionedContinueAsNew, wrapWithResumeContext, writeCommandResponse, writeLatestResponse, } from "./lifecycle.js";
6
6
  import { describeCronAt } from "../cron-at.js";
7
7
  import { shouldWakeParentForChildUpdate } from "../child-notifications.js";
8
8
  import { INTERNAL_SYSTEM_TURN_PROMPT, MAX_RETRIES, SHUTDOWN_POLL_INTERVAL_MS, SHUTDOWN_TIMEOUT_MS, } from "./state.js";
@@ -244,7 +244,15 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
244
244
  let turnResult;
245
245
  try {
246
246
  state.lastLiveSessionAction = "session-activity";
247
- turnResult = yield runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
247
+ // Stop-turn race: the in-flight runTurn activity vs a dequeue on the
248
+ // TURN-SCOPED stop queue (stopTurn.<iteration>). Scoping the queue to
249
+ // the turn index makes stale stop events structurally unable to kill a
250
+ // later turn — a race loser is dropped and cannot be un-dropped.
251
+ // When the stop wins, duroxide cancel-requests the dropped runTurn
252
+ // work item (lock-steal → isCancelled poll → SDK abort) as the
253
+ // guaranteed backstop; handleTurnStopped layers the fast-path
254
+ // same-affinity abortTurn on top.
255
+ const turnTask = runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
248
256
  ...(runtime.options.parentSessionId ? { parentSessionId: runtime.options.parentSessionId } : {}),
249
257
  nestingLevel: runtime.options.nestingLevel,
250
258
  ...(requiredTool ? { requiredTool } : {}),
@@ -252,6 +260,21 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
252
260
  retryCount: state.retryCount,
253
261
  ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
254
262
  });
263
+ const stopTask = ctx.dequeueEvent(stopTurnQueueName(state.iteration));
264
+ const race = yield ctx.race(turnTask, stopTask);
265
+ if (race.index === 1) {
266
+ yield* handleTurnStopped(runtime, race.value);
267
+ return;
268
+ }
269
+ // The select bridge flattens activity failures into their raw error
270
+ // string (duroxide-node make_select_future) instead of throwing, so a
271
+ // failed runTurn must be re-thrown here to reach the existing retry
272
+ // machinery in the catch below.
273
+ const raced = normalizeRacedTurnValue(race.value);
274
+ if (raced.kind === "error") {
275
+ throw new Error(raced.message);
276
+ }
277
+ turnResult = raced.result;
255
278
  }
256
279
  catch (err) {
257
280
  state.config.turnSystemPrompt = undefined;
@@ -314,6 +337,276 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
314
337
  yield* drainLeadingQueuedScheduleActions(runtime, prompt);
315
338
  yield* handleTurnResult(runtime, result, prompt, cycleOrigin);
316
339
  }
340
+ // ─── Stop-turn race support ─────────────────────────────────
341
+ /**
342
+ * Normalize a raced runTurn branch value. The duroxide-node select bridge
343
+ * flattens activity failures into their raw error string (make_select_future:
344
+ * `Ok(v) => v, Err(e) => e`) instead of throwing into the generator, so the
345
+ * caller must distinguish a TurnResult payload from an error message.
346
+ */
347
+ export function normalizeRacedTurnValue(value) {
348
+ let v = value;
349
+ if (typeof v === "string") {
350
+ try {
351
+ v = JSON.parse(v);
352
+ }
353
+ catch {
354
+ return { kind: "error", message: value };
355
+ }
356
+ }
357
+ if (v && typeof v === "object" && typeof v.type === "string") {
358
+ return { kind: "result", result: v };
359
+ }
360
+ return { kind: "error", message: typeof value === "string" ? value : JSON.stringify(value ?? null) };
361
+ }
362
+ /**
363
+ * Stop won the race against the in-flight runTurn activity.
364
+ *
365
+ * The dropped runTurn future is already cancel-requested by duroxide (the
366
+ * guaranteed backstop: lock-steal → isCancelled poll → SDK abort, ~2-7s).
367
+ * This path layers the fast-path interrupt on top and owns the authoritative
368
+ * durable bookkeeping — the aborted activity's own writeback is best-effort
369
+ * (it is skipped entirely when the backstop delivered the abort).
370
+ */
371
+ function* handleTurnStopped(runtime, stopEventRaw) {
372
+ const { ctx, state } = runtime;
373
+ let stopEvent = stopEventRaw;
374
+ if (typeof stopEvent === "string") {
375
+ try {
376
+ stopEvent = JSON.parse(stopEvent);
377
+ }
378
+ catch {
379
+ stopEvent = {};
380
+ }
381
+ }
382
+ if (!stopEvent || typeof stopEvent !== "object")
383
+ stopEvent = {};
384
+ const reason = typeof stopEvent.reason === "string" && stopEvent.reason ? stopEvent.reason : "Stopped by user";
385
+ const stoppedIteration = state.iteration;
386
+ ctx.traceInfo(`[orch] stop_turn won the race for turn ${stoppedIteration}; aborting in-flight turn`);
387
+ state.config.turnSystemPrompt = undefined;
388
+ state.retryCount = 0;
389
+ // Fast-path interrupt: same-affinity abortTurn lands on the worker owning
390
+ // the warm session and aborts the SDK request immediately (concurrent
391
+ // dispatch requires stable workerNodeId + a free slot; otherwise the
392
+ // backstop still stops the turn, just slower). Awaiting it also
393
+ // guarantees the per-session run-turn lock is free again before this
394
+ // loop can dispatch the next prompt.
395
+ let abortOutcome = null;
396
+ try {
397
+ const raw = yield runtime.session.abortTurn(reason, stoppedIteration);
398
+ abortOutcome = typeof raw === "string" ? JSON.parse(raw) : raw;
399
+ }
400
+ catch (err) {
401
+ ctx.traceInfo(`[orch] abortTurn activity failed (backstop cancellation still applies): ${err?.message ?? err}`);
402
+ abortOutcome = { outcome: "no_active_turn", detail: `abortTurn failed: ${err?.message ?? err}` };
403
+ }
404
+ // The race already decided the turn's fate: even when abortTurn reports
405
+ // no_active_turn (the backstop got there first, or the turn had just
406
+ // ended), the user's stop is the durable outcome. Record turn_stopped
407
+ // unconditionally and annotate how the interrupt was delivered.
408
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [
409
+ {
410
+ eventType: "session.turn_stopped",
411
+ data: {
412
+ reason,
413
+ turnIndex: stoppedIteration,
414
+ interrupt: abortOutcome?.outcome ?? "unknown",
415
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
416
+ },
417
+ },
418
+ { eventType: "system.message", data: { content: "Turn stopped by user." } },
419
+ ]);
420
+ // Authoritative CMS transition — also clears active_turn_index (migration
421
+ // 0024 clears it on any state transition away from "running").
422
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
423
+ // The turn ran and consumed context even though its result was discarded.
424
+ state.iteration++;
425
+ if (typeof stopEvent.id === "string" && stopEvent.id) {
426
+ yield* writeCommandResponse(runtime, {
427
+ id: stopEvent.id,
428
+ cmd: "stop_turn",
429
+ result: {
430
+ outcome: abortOutcome?.outcome === "stop_forced" ? "stop_forced" : "stopped",
431
+ turnIndex: stoppedIteration,
432
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
433
+ },
434
+ });
435
+ }
436
+ // Same scheduling semantics as a completed turn: resume interrupted
437
+ // timers, re-arm cron schedules, else idle (skips: writeLatestResponse,
438
+ // parent CHILD_UPDATE notify, forgotten-timer nudge).
439
+ yield* schedulePostTurnContinuation(runtime);
440
+ }
441
+ // ─── Post-turn continuation: resume timers / re-arm schedules / go idle ───
442
+ //
443
+ // Extracted verbatim from the tail of the `completed` turn-result case so the
444
+ // stop-turn path shares identical scheduling semantics: stopping a turn must
445
+ // not silently kill a recurring session's cron loop or a resumable wait
446
+ // (stop-turn plan, edge E9).
447
+ function* schedulePostTurnContinuation(runtime) {
448
+ const { ctx, state, options } = runtime;
449
+ if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
450
+ const saved = state.interruptedWaitTimer;
451
+ state.interruptedWaitTimer = null;
452
+ ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
453
+ if (saved.shouldRehydrate) {
454
+ yield* dehydrateForNextTurn(runtime, "timer", saved.waitPlan?.resetAffinityOnDehydrate ?? true);
455
+ }
456
+ const resumeNow = yield ctx.utcNow();
457
+ publishStatus(runtime, "waiting", {
458
+ waitSeconds: saved.remainingSec,
459
+ waitReason: saved.reason,
460
+ waitStartedAt: resumeNow,
461
+ });
462
+ if (!saved.shouldRehydrate)
463
+ yield* maybeCheckpoint(runtime);
464
+ state.activeTimer = {
465
+ deadlineMs: resumeNow + saved.remainingSec * 1000,
466
+ originalDurationMs: saved.remainingSec * 1000,
467
+ reason: saved.reason,
468
+ type: "wait",
469
+ shouldRehydrate: saved.shouldRehydrate,
470
+ waitPlan: saved.waitPlan,
471
+ };
472
+ return;
473
+ }
474
+ if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
475
+ const saved = state.interruptedCronTimer;
476
+ state.interruptedCronTimer = null;
477
+ const remainingMs = Math.max(0, saved.remainingMs);
478
+ const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
479
+ ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
480
+ const cronResumePlan = planWaitHandling({
481
+ blobEnabled: state.blobEnabled,
482
+ seconds: remainingSec,
483
+ dehydrateThreshold: options.dehydrateThreshold,
484
+ });
485
+ if (cronResumePlan.shouldDehydrate) {
486
+ yield* dehydrateForNextTurn(runtime, "cron", cronResumePlan.resetAffinityOnDehydrate);
487
+ }
488
+ const resumeNow = yield ctx.utcNow();
489
+ publishStatus(runtime, "waiting", {
490
+ waitSeconds: remainingSec,
491
+ waitReason: saved.reason,
492
+ waitStartedAt: resumeNow,
493
+ });
494
+ if (!cronResumePlan.shouldDehydrate)
495
+ yield* maybeCheckpoint(runtime);
496
+ state.activeTimer = {
497
+ deadlineMs: resumeNow + remainingMs,
498
+ originalDurationMs: remainingMs,
499
+ reason: saved.reason,
500
+ type: "cron",
501
+ shouldRehydrate: cronResumePlan.shouldDehydrate,
502
+ };
503
+ return;
504
+ }
505
+ if (state.cronSchedule) {
506
+ const activeCron = { ...state.cronSchedule };
507
+ const cronPlan = planWaitHandling({
508
+ blobEnabled: state.blobEnabled,
509
+ seconds: activeCron.intervalSeconds,
510
+ dehydrateThreshold: options.dehydrateThreshold,
511
+ });
512
+ if (cronPlan.shouldDehydrate) {
513
+ yield* dehydrateForNextTurn(runtime, "cron", cronPlan.resetAffinityOnDehydrate);
514
+ }
515
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
516
+ eventType: "session.cron_started",
517
+ data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
518
+ }]);
519
+ const cronStartedAt = yield ctx.utcNow();
520
+ ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
521
+ publishStatus(runtime, "waiting", {
522
+ waitSeconds: activeCron.intervalSeconds,
523
+ waitReason: activeCron.reason,
524
+ waitStartedAt: cronStartedAt,
525
+ });
526
+ if (!cronPlan.shouldDehydrate)
527
+ yield* maybeCheckpoint(runtime);
528
+ state.activeTimer = {
529
+ deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
530
+ originalDurationMs: activeCron.intervalSeconds * 1000,
531
+ reason: activeCron.reason,
532
+ type: "cron",
533
+ shouldRehydrate: cronPlan.shouldDehydrate,
534
+ };
535
+ return;
536
+ }
537
+ if (state.cronAtSchedule) {
538
+ const activeCronAt = { ...state.cronAtSchedule };
539
+ if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
540
+ state.cronAtSchedule = undefined;
541
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
542
+ eventType: "session.cron_at_completed",
543
+ data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
544
+ }]);
545
+ return;
546
+ }
547
+ const nowMs = yield ctx.utcNow();
548
+ let nextFireAtMs = activeCronAt.nextFireAtMs;
549
+ let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
550
+ if (!nextFireAtMs || !nextOccurrenceKey) {
551
+ const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
552
+ nextFireAtMs = nextFire.nextFireAtMs;
553
+ nextOccurrenceKey = nextFire.occurrenceKey;
554
+ state.cronAtSchedule = {
555
+ ...activeCronAt,
556
+ nextFireAtMs,
557
+ nextOccurrenceKey,
558
+ };
559
+ }
560
+ if (nextFireAtMs === undefined || !nextOccurrenceKey) {
561
+ throw new Error("cron_at next-fire computation did not return a fire time");
562
+ }
563
+ const waitMs = Math.max(0, nextFireAtMs - nowMs);
564
+ const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
565
+ const cronAtPlan = planWaitHandling({
566
+ blobEnabled: state.blobEnabled,
567
+ seconds: waitSeconds,
568
+ dehydrateThreshold: options.dehydrateThreshold,
569
+ });
570
+ if (cronAtPlan.shouldDehydrate) {
571
+ yield* dehydrateForNextTurn(runtime, "cron_at", cronAtPlan.resetAffinityOnDehydrate);
572
+ }
573
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
574
+ eventType: "session.cron_at_started",
575
+ data: {
576
+ ...state.cronAtSchedule,
577
+ nextFireAt: new Date(nextFireAtMs).toISOString(),
578
+ },
579
+ }]);
580
+ publishStatus(runtime, "waiting", {
581
+ waitSeconds,
582
+ waitReason: activeCronAt.reason,
583
+ waitStartedAt: nowMs,
584
+ });
585
+ if (!cronAtPlan.shouldDehydrate)
586
+ yield* maybeCheckpoint(runtime);
587
+ state.activeTimer = {
588
+ deadlineMs: nowMs + waitMs,
589
+ originalDurationMs: waitMs,
590
+ reason: activeCronAt.reason,
591
+ type: "cron_at",
592
+ shouldRehydrate: cronAtPlan.shouldDehydrate,
593
+ };
594
+ return;
595
+ }
596
+ if (!state.blobEnabled || options.idleTimeout < 0) {
597
+ yield* maybeCheckpoint(runtime);
598
+ return;
599
+ }
600
+ publishStatus(runtime, "idle");
601
+ yield* maybeCheckpoint(runtime);
602
+ const idleNow = yield ctx.utcNow();
603
+ state.activeTimer = {
604
+ deadlineMs: idleNow + options.idleTimeout * 1000,
605
+ originalDurationMs: options.idleTimeout * 1000,
606
+ reason: "idle timeout",
607
+ type: "idle",
608
+ };
609
+ }
317
610
  // ─── handleTurnResult: dispatch on TurnResult variant ───────
318
611
  function coerceChildQuestionToWait(runtime, result) {
319
612
  if (result.type === "completed"
@@ -427,166 +720,7 @@ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
427
720
  return;
428
721
  }
429
722
  }
430
- if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
431
- const saved = state.interruptedWaitTimer;
432
- state.interruptedWaitTimer = null;
433
- ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
434
- if (saved.shouldRehydrate) {
435
- yield* dehydrateForNextTurn(runtime, "timer", saved.waitPlan?.resetAffinityOnDehydrate ?? true);
436
- }
437
- const resumeNow = yield ctx.utcNow();
438
- publishStatus(runtime, "waiting", {
439
- waitSeconds: saved.remainingSec,
440
- waitReason: saved.reason,
441
- waitStartedAt: resumeNow,
442
- });
443
- if (!saved.shouldRehydrate)
444
- yield* maybeCheckpoint(runtime);
445
- state.activeTimer = {
446
- deadlineMs: resumeNow + saved.remainingSec * 1000,
447
- originalDurationMs: saved.remainingSec * 1000,
448
- reason: saved.reason,
449
- type: "wait",
450
- shouldRehydrate: saved.shouldRehydrate,
451
- waitPlan: saved.waitPlan,
452
- };
453
- return;
454
- }
455
- if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
456
- const saved = state.interruptedCronTimer;
457
- state.interruptedCronTimer = null;
458
- const remainingMs = Math.max(0, saved.remainingMs);
459
- const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
460
- ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
461
- const cronResumePlan = planWaitHandling({
462
- blobEnabled: state.blobEnabled,
463
- seconds: remainingSec,
464
- dehydrateThreshold: options.dehydrateThreshold,
465
- });
466
- if (cronResumePlan.shouldDehydrate) {
467
- yield* dehydrateForNextTurn(runtime, "cron", cronResumePlan.resetAffinityOnDehydrate);
468
- }
469
- const resumeNow = yield ctx.utcNow();
470
- publishStatus(runtime, "waiting", {
471
- waitSeconds: remainingSec,
472
- waitReason: saved.reason,
473
- waitStartedAt: resumeNow,
474
- });
475
- if (!cronResumePlan.shouldDehydrate)
476
- yield* maybeCheckpoint(runtime);
477
- state.activeTimer = {
478
- deadlineMs: resumeNow + remainingMs,
479
- originalDurationMs: remainingMs,
480
- reason: saved.reason,
481
- type: "cron",
482
- shouldRehydrate: cronResumePlan.shouldDehydrate,
483
- };
484
- return;
485
- }
486
- if (state.cronSchedule) {
487
- const activeCron = { ...state.cronSchedule };
488
- const cronPlan = planWaitHandling({
489
- blobEnabled: state.blobEnabled,
490
- seconds: activeCron.intervalSeconds,
491
- dehydrateThreshold: options.dehydrateThreshold,
492
- });
493
- if (cronPlan.shouldDehydrate) {
494
- yield* dehydrateForNextTurn(runtime, "cron", cronPlan.resetAffinityOnDehydrate);
495
- }
496
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
497
- eventType: "session.cron_started",
498
- data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
499
- }]);
500
- const cronStartedAt = yield ctx.utcNow();
501
- ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
502
- publishStatus(runtime, "waiting", {
503
- waitSeconds: activeCron.intervalSeconds,
504
- waitReason: activeCron.reason,
505
- waitStartedAt: cronStartedAt,
506
- });
507
- if (!cronPlan.shouldDehydrate)
508
- yield* maybeCheckpoint(runtime);
509
- state.activeTimer = {
510
- deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
511
- originalDurationMs: activeCron.intervalSeconds * 1000,
512
- reason: activeCron.reason,
513
- type: "cron",
514
- shouldRehydrate: cronPlan.shouldDehydrate,
515
- };
516
- return;
517
- }
518
- if (state.cronAtSchedule) {
519
- const activeCronAt = { ...state.cronAtSchedule };
520
- if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
521
- state.cronAtSchedule = undefined;
522
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
523
- eventType: "session.cron_at_completed",
524
- data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
525
- }]);
526
- return;
527
- }
528
- const nowMs = yield ctx.utcNow();
529
- let nextFireAtMs = activeCronAt.nextFireAtMs;
530
- let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
531
- if (!nextFireAtMs || !nextOccurrenceKey) {
532
- const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
533
- nextFireAtMs = nextFire.nextFireAtMs;
534
- nextOccurrenceKey = nextFire.occurrenceKey;
535
- state.cronAtSchedule = {
536
- ...activeCronAt,
537
- nextFireAtMs,
538
- nextOccurrenceKey,
539
- };
540
- }
541
- if (nextFireAtMs === undefined || !nextOccurrenceKey) {
542
- throw new Error("cron_at next-fire computation did not return a fire time");
543
- }
544
- const waitMs = Math.max(0, nextFireAtMs - nowMs);
545
- const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
546
- const cronAtPlan = planWaitHandling({
547
- blobEnabled: state.blobEnabled,
548
- seconds: waitSeconds,
549
- dehydrateThreshold: options.dehydrateThreshold,
550
- });
551
- if (cronAtPlan.shouldDehydrate) {
552
- yield* dehydrateForNextTurn(runtime, "cron_at", cronAtPlan.resetAffinityOnDehydrate);
553
- }
554
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
555
- eventType: "session.cron_at_started",
556
- data: {
557
- ...state.cronAtSchedule,
558
- nextFireAt: new Date(nextFireAtMs).toISOString(),
559
- },
560
- }]);
561
- publishStatus(runtime, "waiting", {
562
- waitSeconds,
563
- waitReason: activeCronAt.reason,
564
- waitStartedAt: nowMs,
565
- });
566
- if (!cronAtPlan.shouldDehydrate)
567
- yield* maybeCheckpoint(runtime);
568
- state.activeTimer = {
569
- deadlineMs: nowMs + waitMs,
570
- originalDurationMs: waitMs,
571
- reason: activeCronAt.reason,
572
- type: "cron_at",
573
- shouldRehydrate: cronAtPlan.shouldDehydrate,
574
- };
575
- return;
576
- }
577
- if (!state.blobEnabled || options.idleTimeout < 0) {
578
- yield* maybeCheckpoint(runtime);
579
- return;
580
- }
581
- publishStatus(runtime, "idle");
582
- yield* maybeCheckpoint(runtime);
583
- const idleNow = yield ctx.utcNow();
584
- state.activeTimer = {
585
- deadlineMs: idleNow + options.idleTimeout * 1000,
586
- originalDurationMs: options.idleTimeout * 1000,
587
- reason: "idle timeout",
588
- type: "idle",
589
- };
723
+ yield* schedulePostTurnContinuation(runtime);
590
724
  return;
591
725
  }
592
726
  case "cron":
@@ -706,6 +840,26 @@ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
706
840
  case "cancelled":
707
841
  ctx.traceInfo("[session] turn cancelled");
708
842
  return;
843
+ case "stopped": {
844
+ // Defensive: a turn only classifies "stopped" when the stop marker
845
+ // was set, which normally means handleTurnStopped already ran via
846
+ // the race. Handle it anyway so a marker-set turn that somehow
847
+ // returns through the normal path still lands idle with the event
848
+ // trail (processPrompt already incremented state.iteration).
849
+ ctx.traceInfo("[session] turn reported stopped");
850
+ state.retryCount = 0;
851
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
852
+ eventType: "session.turn_stopped",
853
+ data: {
854
+ reason: result.reason ?? "Stopped by user",
855
+ turnIndex: state.iteration - 1,
856
+ interrupt: "turn-result",
857
+ },
858
+ }]);
859
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
860
+ yield* schedulePostTurnContinuation(runtime);
861
+ return;
862
+ }
709
863
  case "spawn_agent":
710
864
  case "message_agent":
711
865
  case "check_agents":