@zq-silk/yui 0.6.8 → 0.6.10

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 (58) hide show
  1. package/README.md +10 -3
  2. package/dist/cli/commandCatalog.js +1 -1
  3. package/dist/commands/taskCommands.js +63 -28
  4. package/dist/commands/taskContextCommand.js +27 -1
  5. package/dist/commands/taskRoleRuntimeStatus.js +17 -6
  6. package/dist/controller/agentRuntimeObserver.js +6 -3
  7. package/dist/controller/controller.js +29 -47
  8. package/dist/controller/fileSchedulerStoreAdapter.js +572 -258
  9. package/dist/controller/runtime.js +8 -1
  10. package/dist/controller/runtimeEventInbox.js +16 -5
  11. package/dist/controller/runtimeHookRunFence.js +51 -5
  12. package/dist/controller/runtimeObservationHook.js +8 -2
  13. package/dist/coordination/workMailbox.js +408 -28
  14. package/dist/coordination/workMailboxQueue.js +12 -10
  15. package/dist/executor/agentExecutor.js +101 -94
  16. package/dist/executor/executorRegistry.js +47 -2
  17. package/dist/executor/fileRoleLaunchPlanner.js +4 -2
  18. package/dist/lifecycle/exactRunTerminalization.js +1 -7
  19. package/dist/repository/taskWorkspaceCoordinator.js +9 -4
  20. package/dist/runtime/agentDriver.js +83 -4
  21. package/dist/runtime/agentDriverObservation.js +25 -10
  22. package/dist/runtime/builtinAgentDrivers.js +168 -18
  23. package/dist/runtime/codexAppServerRuntime.js +355 -0
  24. package/dist/runtime/continuationManager.js +117 -0
  25. package/dist/runtime/index.js +2 -0
  26. package/dist/runtime/lifecycleReservation.js +4 -3
  27. package/dist/runtime/promptEnvelope.js +14 -3
  28. package/dist/runtime/providerContinuation.js +225 -0
  29. package/dist/runtime/providerContinuationReconciliationService.js +172 -0
  30. package/dist/runtime/providerRuntimeIdentity.js +232 -0
  31. package/dist/runtime/providerRuntimeReconciler.js +166 -0
  32. package/dist/runtime/runtimeContinuationProjection.js +34 -0
  33. package/dist/runtime/runtimeObservation.js +217 -6
  34. package/dist/runtime/runtimeProjection.js +172 -11
  35. package/dist/scheduler/activeRoleRunDelivery.js +314 -1
  36. package/dist/scheduler/leaderWakeupProcessor.js +2 -1
  37. package/dist/scheduler/operatorInputNotificationProcessor.js +3 -2
  38. package/dist/scheduler/roleRunLiveness.js +8 -7
  39. package/dist/scheduler/roleRunStall.js +4 -2
  40. package/dist/scheduler/taskExecutionProjection.js +2 -2
  41. package/dist/storage/migration/productionRegistry.js +474 -1
  42. package/dist/storage/sqliteSchema.js +102 -21
  43. package/dist/storage/sqliteStore.js +52 -110
  44. package/dist/storage/storageVersions.js +1 -1
  45. package/dist/storage/storeRpc.js +0 -1
  46. package/dist/storage/taskStore.js +40 -53
  47. package/dist/storage/upgrade/sqliteStateMigration.js +0 -21
  48. package/dist/task/nextAction.js +1 -1
  49. package/dist/web/assets/client/app.js +1 -1
  50. package/dist/web/assets/client/components.js +233 -4
  51. package/dist/web/assets/client/i18n.js +166 -2
  52. package/dist/web/assets/client/view.js +30 -13
  53. package/dist/web/assets/styles/cards.js +62 -0
  54. package/dist/web/assets/styles/widgets.js +1 -0
  55. package/dist/web/webSnapshot.js +11 -2
  56. package/package.json +1 -1
  57. package/skills/yui-leader/SKILL.md +33 -24
  58. package/skills/yui-operator/SKILL.md +7 -5
@@ -1,19 +1,28 @@
1
1
  import { selectedSchedulerRoles, selectedActiveSchedulerTasks } from "./ports.js";
2
2
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
3
3
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
4
+ import { markYuiRunInput } from "../run/runIdentity.js";
5
+ import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
4
6
  import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
5
7
  import { RuntimeLaunchError } from "../runtime/ports.js";
6
8
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
9
+ import { mailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
7
10
  /**
8
11
  * Delivers durable Work AgentRuns before liveness reconciliation. Task command
9
12
  * handlers only record intent; this Controller path is the sole automated
10
13
  * route into the Agent terminal, through tmux receipt-backed delivery.
11
14
  */
12
- export async function processActiveRoleRunDeliveries(store, delivery, now, selection) {
15
+ export async function processActiveRoleRunDeliveries(store, delivery, now, selection, inputDeliveryRecoveryCutoff) {
13
16
  const results = [];
14
17
  for (const task of selectedActiveSchedulerTasks(store, selection)) {
15
18
  for (const role of selectedSchedulerRoles(store, task.id, selection)) {
16
19
  const run = store.getActiveAgentRun(task.id, role.name);
20
+ if (run !== null && run.pushedAt !== undefined) {
21
+ const continuation = await processActiveRunContinuation(store, delivery, task, role, run, now, inputDeliveryRecoveryCutoff);
22
+ if (continuation !== null)
23
+ results.push(continuation);
24
+ continue;
25
+ }
17
26
  // A crash after a Leader wake is durably claimed but before tmux input
18
27
  // is recoverable through the same receipt-backed delivery path. The
19
28
  // re-push guard keys on pushedAt (transport), not deliveredAt (provider
@@ -308,6 +317,310 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
308
317
  }
309
318
  return results;
310
319
  }
320
+ async function processActiveRunContinuation(store, delivery, task, role, run, now, inputDeliveryRecoveryCutoff) {
321
+ const target = { kind: "role", taskId: task.id, roleName: role.name };
322
+ const mailbox = store.getWorkMailbox(target);
323
+ if (mailbox === null || !mailboxHasWork(mailbox))
324
+ return null;
325
+ const session = store.getRoleSession(task.id, role.name, run.effective.agentId);
326
+ if (session === null || session.launchId === undefined || !hasText(session.nativeSessionId)) {
327
+ return {
328
+ taskId: task.id,
329
+ roleName: role.name,
330
+ runId: run.id,
331
+ status: "skipped",
332
+ reason: "not-ready"
333
+ };
334
+ }
335
+ const originalReceipt = formatAgentRunReceiptId(task.id, run.id);
336
+ if (mailbox.processing !== null && (mailbox.processing.executionRef?.type !== "run"
337
+ || mailbox.processing.executionRef.taskId !== task.id
338
+ || mailbox.processing.executionRef.id !== run.id
339
+ || mailbox.processing.batchId !== originalReceipt)) {
340
+ return {
341
+ taskId: task.id,
342
+ roleName: role.name,
343
+ runId: run.id,
344
+ status: "skipped",
345
+ reason: "mailbox-busy"
346
+ };
347
+ }
348
+ if (mailbox.inputDelivery !== null) {
349
+ const existing = mailbox.inputDelivery;
350
+ if ((existing.status === "delivery-unknown"
351
+ || (existing.status === "dispatching"
352
+ && inputDeliveryRecoveryCutoff !== undefined
353
+ && Date.parse(existing.startedAt) < inputDeliveryRecoveryCutoff.getTime()))) {
354
+ return reconcileStrandedInputDelivery(store, delivery, task, role, run, session, existing, now);
355
+ }
356
+ return {
357
+ taskId: task.id,
358
+ roleName: role.name,
359
+ runId: run.id,
360
+ status: "skipped",
361
+ reason: "delivery-uncertain"
362
+ };
363
+ }
364
+ const pending = nextPendingBatch(mailbox);
365
+ const lane = mailbox.pending.userCorrection === pending ? "user-correction" : "normal";
366
+ const requestedBatchId = pending === null
367
+ ? originalReceipt
368
+ : `agent-input:${task.id}/${run.id}/${lane}:${pending.fromSequence}-${pending.toSequence}`;
369
+ if (requestedBatchId === originalReceipt) {
370
+ // Initial provider acceptance is still outstanding. It owns this claim;
371
+ // never reinterpret the original Run prompt as a continuation.
372
+ return {
373
+ taskId: task.id,
374
+ roleName: role.name,
375
+ runId: run.id,
376
+ status: "skipped",
377
+ reason: "delivery-uncertain"
378
+ };
379
+ }
380
+ if (pending === null || session.launchId === undefined)
381
+ return null;
382
+ if (session.status === "broken") {
383
+ return {
384
+ taskId: task.id,
385
+ roleName: role.name,
386
+ runId: run.id,
387
+ status: "skipped",
388
+ reason: "runtime-unavailable"
389
+ };
390
+ }
391
+ const activeTurn = store.getActiveProviderTurnFence?.({
392
+ taskId: task.id,
393
+ roleName: role.name,
394
+ runId: run.id,
395
+ agentId: run.effective.agentId,
396
+ launchId: session.launchId,
397
+ nativeSessionId: session.nativeSessionId
398
+ }) ?? null;
399
+ const mode = lane === "user-correction"
400
+ ? activeTurn !== null && delivery.canRouteProviderInput?.(run.effective.adapterId) === true
401
+ ? "steer-if-safe"
402
+ : "followup"
403
+ : pending.deliveryModes.includes("followup") ? "followup" : "inject";
404
+ // Normal facts never enter an active Turn. A user correction may steer only
405
+ // through the exact Turn fence above; without it, retain the high-priority
406
+ // lane until the current Turn ends and deliver it as the next followup.
407
+ if (mode === "followup" && session.status === "running") {
408
+ return {
409
+ taskId: task.id,
410
+ roleName: role.name,
411
+ runId: run.id,
412
+ status: "skipped",
413
+ reason: "not-ready"
414
+ };
415
+ }
416
+ if (mode === "inject" && (delivery.routeProviderInput === undefined
417
+ || delivery.canRouteProviderInput?.(run.effective.adapterId) !== true)) {
418
+ return {
419
+ taskId: task.id,
420
+ roleName: role.name,
421
+ runId: run.id,
422
+ status: "skipped",
423
+ reason: "delivery-unsupported"
424
+ };
425
+ }
426
+ let deliveryAttempted = false;
427
+ let inputDelivery;
428
+ try {
429
+ const prepared = await delivery.prepareRoleSession({
430
+ taskId: task.id,
431
+ roleName: role.name,
432
+ agentId: run.effective.agentId,
433
+ adapterId: run.effective.adapterId,
434
+ effective: run.effective,
435
+ workspace: run.effective.workspace.root,
436
+ ...(run.workspace === undefined ? {} : { managedWorkspace: run.workspace }),
437
+ mode: "resume",
438
+ runId: run.id,
439
+ nativeSessionId: session.nativeSessionId
440
+ });
441
+ const ready = await delivery.waitUntilReady(prepared);
442
+ const readySession = ready.session ?? session;
443
+ if (readySession.launchId === undefined || !hasText(readySession.nativeSessionId)) {
444
+ throw new Error("Continuation delivery has no Provider Activation fence.");
445
+ }
446
+ const providerFence = mode === "steer-if-safe"
447
+ ? activeTurn
448
+ : {
449
+ conversationId: readySession.nativeSessionId,
450
+ activationId: readySession.launchId
451
+ };
452
+ const claim = store.claimInputDelivery({
453
+ target,
454
+ attemptId: requestedBatchId,
455
+ lane,
456
+ mode,
457
+ owner: "controller",
458
+ now,
459
+ executionRef: { type: "run", taskId: task.id, id: run.id },
460
+ providerFence
461
+ });
462
+ if (claim.status === "empty")
463
+ return null;
464
+ if (claim.delivery.attemptId !== requestedBatchId || claim.status === "delivery") {
465
+ return {
466
+ taskId: task.id,
467
+ roleName: role.name,
468
+ runId: run.id,
469
+ status: "skipped",
470
+ reason: "delivery-uncertain"
471
+ };
472
+ }
473
+ inputDelivery = claim.delivery;
474
+ deliveryAttempted = true;
475
+ const text = continuationInput(task, role, run, inputDelivery.attemptId, inputDelivery.batch);
476
+ if (mode !== "followup") {
477
+ const routed = await delivery.routeProviderInput({
478
+ delivery: ready,
479
+ attemptId: inputDelivery.attemptId,
480
+ mode,
481
+ text,
482
+ fence: providerFence
483
+ });
484
+ if (routed === "accepted") {
485
+ store.completeInputDelivery(target, inputDelivery.attemptId, now);
486
+ return { taskId: task.id, roleName: role.name, runId: run.id, status: "delivered" };
487
+ }
488
+ if (routed === "unknown") {
489
+ store.markInputDeliveryUnknown(target, inputDelivery.attemptId, "Provider input acceptance could not be reconciled.", now);
490
+ return {
491
+ taskId: task.id,
492
+ roleName: role.name,
493
+ runId: run.id,
494
+ status: "skipped",
495
+ reason: "delivery-uncertain"
496
+ };
497
+ }
498
+ store.releaseInputDelivery(target, inputDelivery.attemptId);
499
+ return {
500
+ taskId: task.id,
501
+ roleName: role.name,
502
+ runId: run.id,
503
+ status: "skipped",
504
+ reason: routed === "unavailable" ? "runtime-unavailable" : "not-ready"
505
+ };
506
+ }
507
+ const outcome = await delivery.sendOnce({
508
+ delivery: ready,
509
+ receiptId: inputDelivery.attemptId,
510
+ text
511
+ });
512
+ if (outcome === "busy" || outcome === "unavailable") {
513
+ store.releaseInputDelivery(target, inputDelivery.attemptId);
514
+ return {
515
+ taskId: task.id,
516
+ roleName: role.name,
517
+ runId: run.id,
518
+ status: "skipped",
519
+ reason: outcome === "busy" ? "not-ready" : "runtime-unavailable"
520
+ };
521
+ }
522
+ store.markInputDeliveryPushed(target, inputDelivery.attemptId, now);
523
+ // Keep the exact claim until the matching provider turn.accepted Hook
524
+ // folds it. sendOnce is receipt-idempotent, so Controller recovery cannot
525
+ // inject a second Enter for the same batch.
526
+ return {
527
+ taskId: task.id,
528
+ roleName: role.name,
529
+ runId: run.id,
530
+ status: outcome === "sent" ? "delivered" : "already-delivered"
531
+ };
532
+ }
533
+ catch (error) {
534
+ if (inputDelivery === undefined) {
535
+ // No durable input intent existed and no provider input method was
536
+ // called. Session preparation may be retried safely.
537
+ }
538
+ else if (!deliveryAttempted)
539
+ store.releaseInputDelivery(target, inputDelivery.attemptId);
540
+ else
541
+ store.markInputDeliveryUnknown(target, inputDelivery.attemptId, error instanceof Error ? error.message : String(error), now);
542
+ return {
543
+ taskId: task.id,
544
+ roleName: role.name,
545
+ runId: run.id,
546
+ status: "skipped",
547
+ reason: deliveryAttempted ? "delivery-uncertain" : "runtime-unavailable",
548
+ error: error instanceof Error ? error.message : String(error)
549
+ };
550
+ }
551
+ }
552
+ async function reconcileStrandedInputDelivery(store, delivery, task, role, run, session, input, now) {
553
+ const target = { kind: "role", taskId: task.id, roleName: role.name };
554
+ const uncertain = (reason) => {
555
+ store.markInputDeliveryUnknown(target, input.attemptId, reason, now);
556
+ return {
557
+ taskId: task.id,
558
+ roleName: role.name,
559
+ runId: run.id,
560
+ status: "skipped",
561
+ reason: "delivery-uncertain"
562
+ };
563
+ };
564
+ if (input.providerFence === undefined
565
+ || delivery.reconcileProviderInput === undefined
566
+ || delivery.canRouteProviderInput?.(run.effective.adapterId) !== true) {
567
+ return uncertain("Controller restarted without exact Provider input readback.");
568
+ }
569
+ try {
570
+ // Exact readback is addressed entirely from the durable fence. It must not
571
+ // resume a Session, create an Activation, or call any model-starting port.
572
+ const reconciled = await delivery.reconcileProviderInput({
573
+ taskId: task.id,
574
+ roleName: role.name,
575
+ agentId: run.effective.agentId,
576
+ adapterId: run.effective.adapterId,
577
+ launchId: session.launchId,
578
+ nativeSessionId: session.nativeSessionId,
579
+ attemptId: input.attemptId,
580
+ mode: input.mode,
581
+ fence: input.providerFence
582
+ });
583
+ if (reconciled === "accepted") {
584
+ store.completeInputDelivery(target, input.attemptId, now);
585
+ return {
586
+ taskId: task.id,
587
+ roleName: role.name,
588
+ runId: run.id,
589
+ status: "already-delivered"
590
+ };
591
+ }
592
+ if (reconciled === "not-accepted") {
593
+ store.resolveInputDeliveryNotAccepted(target, input.attemptId);
594
+ return {
595
+ taskId: task.id,
596
+ roleName: role.name,
597
+ runId: run.id,
598
+ status: "skipped",
599
+ reason: "not-ready"
600
+ };
601
+ }
602
+ return uncertain(reconciled === "unavailable"
603
+ ? "Provider input readback is unavailable after Controller restart."
604
+ : "Provider input acceptance remains unknown after metadata readback.");
605
+ }
606
+ catch (error) {
607
+ return uncertain(`Provider input readback failed: ${error instanceof Error ? error.message : String(error)}`);
608
+ }
609
+ }
610
+ function continuationInput(task, role, run, attemptId, batch) {
611
+ const references = batch.refs.map((ref) => ("taskId" in ref
612
+ ? `${ref.type}:${ref.taskId}/${ref.id}`
613
+ : `${ref.type}:${ref.id}`));
614
+ return markYuiRunInput([
615
+ `Yui Task Event Batch: ${attemptId}.`,
616
+ "New durable task events are available for the current Yui Run.",
617
+ "Read the referenced shared context through the Yui CLI, incorporate it, and decide whether to continue work or wait for more results.",
618
+ "Do not create a new Yui Run merely because this is a new Provider Turn.",
619
+ `Reasons: ${batch.reasons.join(", ")}.`,
620
+ ...(batch.sources.length === 0 ? [] : [`Sources: ${batch.sources.join(", ")}.`]),
621
+ ...(references.length === 0 ? [] : [`References: ${references.join(", ")}.`])
622
+ ].join("\n"), run.id, taskRoleSessionTitle(task, role.name));
623
+ }
311
624
  function roleRunDeliveryFailure(run, mailboxBatchId, session, launchId) {
312
625
  return {
313
626
  taskId: run.taskId,
@@ -410,7 +410,8 @@ function leaderWakeupInput(taskId, runId, reasons, projectBindings) {
410
410
  : `Project Policy references: ${projectBindings.map((binding) => `${binding.directory} (${binding.projectId})`).join(", ")}. Read each with yui project show <project>, then yui project knowledge list <project> and yui project knowledge show <project> <knowledge>.`,
411
411
  "Use narrower Task message, WorkItem, decision, milestone, and input commands only when a specific record needs closer inspection.",
412
412
  `When the requested outcome is finished and there are no active Worker Runs or unresolved inputs, complete the Task with yui task complete ${taskId} --summary-file - and a quoted heredoc containing the final outcome and evidence.`,
413
- `Before ending this turn, if the Task was not completed and no InputRequest terminalized this Run, release the active fence with yui task run yield ${runId} --summary-file - and a quoted heredoc containing the current result or waiting state. In particular, yield before waiting for Worker results; do not end the native turn while this Run remains active. The yield command must be the final tool action: after it succeeds, stop immediately and do not inspect, poll, accept, or perform further work in the same native turn.`
413
+ "Provider-native subagents remain inside this Leader AgentRun. Yui observes their structured lifecycle and keeps the Run active across intermediate provider Turn boundaries while children remain active; let the provider deliver completion notifications and continue synthesis without polling or yielding merely for that native wait.",
414
+ `A Provider Turn ending does not end this Yui Run. Leave the Run active when later native-subagent results, managed Role results, reviewer results, or user corrections still belong to the same objective; Yui will aggregate those durable events and resume this Session with another Turn. Use yui task run yield ${runId} --summary-file - only for a deliberate Yui-level handoff that should close this Run rather than for ordinary waiting. If used, the yield command must be the final tool action: after it succeeds, stop immediately.`
414
415
  ];
415
416
  return lines.join("\n");
416
417
  }
@@ -1,12 +1,13 @@
1
1
  import { createInputRequestOperatorPresentation, createLeaderRecoveryOperatorPresentation, createLeaderStallOperatorPresentation, createTaskTerminalOperatorPresentation } from "../interaction/operatorPresentation.js";
2
+ import { mailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
2
3
  export async function processOperatorInputNotifications(store, delivery, selection, now = new Date()) {
3
4
  if (selection !== undefined && !selection.full && !selection.operator)
4
5
  return [];
5
6
  const targetMailbox = { kind: "operator" };
6
7
  const mailbox = store.getWorkMailbox(targetMailbox);
7
- if (mailbox === null || (mailbox.pending === null && mailbox.processing === null))
8
+ if (mailbox === null || !mailboxHasWork(mailbox))
8
9
  return [];
9
- const pending = mailbox.pending;
10
+ const pending = nextPendingBatch(mailbox);
10
11
  const claim = store.claimWorkMailbox({
11
12
  target: targetMailbox,
12
13
  batchId: pending === null
@@ -4,8 +4,12 @@ import { queueLeaderWakeup } from "./wakeupQueue.js";
4
4
  import { currentRoleRunProgressAt, DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS } from "./roleRunStall.js";
5
5
  export const EXITED_ROLE_RUN_SUMMARY = "The role's tmux session exited before the run yielded.";
6
6
  /**
7
- * Lightweight liveness only: an active AgentRun whose tmux role is absent is
8
- * failed, then the Leader is durably queued. No TTL, cooldown, or schedules.
7
+ * Lightweight liveness only. Host absence may fail a Run only before any
8
+ * prompt bytes were pushed. After push, acceptance may be unknown even when
9
+ * deliveredAt is absent; pane/process loss is therefore runtime-health
10
+ * evidence, not an application-level outcome. The Run stays active so native
11
+ * child work or another observer can still contribute facts, while the stall
12
+ * path raises bounded attention independently.
9
13
  */
10
14
  export async function reconcileExitedRoleRuns(store, delivery, now, selection, excludedRunRefs = new Set(), liveStatuses, resourceEvidence, targetedInventory = selection !== undefined && !selection.full) {
11
15
  const failed = [];
@@ -35,11 +39,8 @@ export async function reconcileExitedRoleRuns(store, delivery, now, selection, e
35
39
  })));
36
40
  if (candidates.length === 0)
37
41
  return failed;
38
- const candidateTaskIds = [...new Set(candidates.map(({ task }) => task.id))]
39
- .sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
40
- const completing = new Set(store.listPendingRuntimeTurnCompletions(candidateTaskIds).map((completion) => (`${completion.taskId}\0${completion.roleName}\0${completion.runId}`)));
41
- const eligible = candidates.filter(({ task, role, run }) => (!excludedRunRefs.has(formatTaskRecordReference(task.id, run.id, "agentRun"))
42
- && !completing.has(`${task.id}\0${role.name}\0${run.id}`)));
42
+ const eligible = candidates.filter(({ task, run }) => (!excludedRunRefs.has(formatTaskRecordReference(task.id, run.id, "agentRun"))
43
+ && run.pushedAt === undefined));
43
44
  // Full reconciliation builds one complete provider inventory for every
44
45
  // active Run, including delivery-uncertain and completion-pending Runs.
45
46
  // The stall phase reuses that snapshot so one full pass never probes the
@@ -1,4 +1,5 @@
1
1
  import { selectedSchedulerRoles, selectedActiveSchedulerTasks } from "./ports.js";
2
+ import { nextPendingBatch } from "../coordination/workMailbox.js";
2
3
  /**
3
4
  * Default window of no durable progress before a live-but-idle Run becomes a
4
5
  * traceable needs-attention signal. It is deliberately long: a healthy Run that
@@ -845,7 +846,7 @@ function classifyLeaderStall(store, taskId, observed, now, windowMs) {
845
846
  const downstreamPresent = downstream.some((entry) => entry.live === "present");
846
847
  const leader = [...observed.values()].find((entry) => (entry.candidate.task.id === taskId && entry.candidate.role.name === "leader"));
847
848
  const mailbox = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" });
848
- const pending = mailbox?.pending;
849
+ const pending = mailbox === null || mailbox === undefined ? null : nextPendingBatch(mailbox);
849
850
  const processing = mailbox?.processing;
850
851
  const processingCurrent = processing?.executionRef?.type === "run"
851
852
  && processing.executionRef.taskId === taskId
@@ -952,7 +953,8 @@ function leaderStallEvidence(store, taskId, observed, now, windowMs) {
952
953
  const active = downstream.filter((entry) => entry.candidate.run !== null).length;
953
954
  const healthy = downstream.filter((entry) => entry.live === "present" && !entry.stalled).length;
954
955
  const stalled = downstream.filter((entry) => entry.live === "present" && entry.stalled).length;
955
- const pending = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" })?.pending;
956
+ const leaderMailbox = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" });
957
+ const pending = leaderMailbox === null ? null : nextPendingBatch(leaderMailbox);
956
958
  const pendingAge = pending === undefined || pending === null
957
959
  ? "none"
958
960
  : Number.isFinite(Date.parse(pending.lastQueuedAt))
@@ -1,4 +1,5 @@
1
1
  import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
2
+ import { mailboxBatches } from "../coordination/workMailbox.js";
2
3
  import { summarizeExecutionGroup } from "../execution/executionGroup.js";
3
4
  import { isRoleRunStalled, latestStallProgressAt } from "./roleRunStall.js";
4
5
  /**
@@ -537,8 +538,7 @@ function isRecoveryPending(wakeup, mailbox, failure, notification) {
537
538
  return false;
538
539
  const reasons = [
539
540
  ...(wakeup?.reasons ?? []),
540
- ...(mailbox?.pending?.reasons ?? []),
541
- ...(mailbox?.processing?.batch.reasons ?? [])
541
+ ...(mailbox === null ? [] : mailboxBatches(mailbox).flatMap((batch) => batch.reasons))
542
542
  ];
543
543
  return reasons.some((reason) => /(?:recover|stalled|failed|uncertain|orphan)/iu.test(reason));
544
544
  }