@zq-silk/yui 2.8.1 → 2.10.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 (119) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +56 -21
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli/interactionPolicy.js +2 -2
  5. package/dist/commands/durableJobCommands.js +50 -0
  6. package/dist/commands/globalRoleCommands.js +30 -9
  7. package/dist/commands/inboxCommands.js +79 -22
  8. package/dist/commands/projectCommands.js +4 -33
  9. package/dist/commands/scheduleCommands.js +45 -0
  10. package/dist/commands/taskActivationCommands.js +19 -5
  11. package/dist/commands/taskCommands.js +8 -2
  12. package/dist/commands/taskFactCommands.js +24 -12
  13. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  14. package/dist/commands/taskPublicationCommands.js +6 -3
  15. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  16. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  17. package/dist/context/runContextPack.js +25 -4
  18. package/dist/context/runInputContract.js +1 -0
  19. package/dist/context/taskContext.js +53 -16
  20. package/dist/context/taskPhase.js +99 -0
  21. package/dist/controlPlaneCli.js +38 -17
  22. package/dist/controller/controller.js +35 -0
  23. package/dist/controller/fileSchedulerStoreAdapter.js +75 -16
  24. package/dist/controller/globalInputDelivery.js +17 -7
  25. package/dist/controller/inboxTurnCheck.js +64 -0
  26. package/dist/controller/jobControl.js +42 -12
  27. package/dist/controller/jobDefinitionControl.js +304 -0
  28. package/dist/controller/jobSupervisor.js +47 -32
  29. package/dist/controller/providerRetryAdmission.js +2 -0
  30. package/dist/controller/runtime.js +11 -2
  31. package/dist/controller/scheduleControl.js +176 -0
  32. package/dist/coordination/workMailbox.js +3 -3
  33. package/dist/external/githubPublicationVerifier.js +14 -5
  34. package/dist/external/gitlabPublicationVerifier.js +12 -29
  35. package/dist/grant/taskAuthorization.js +7 -2
  36. package/dist/interaction/notificationPresentation.js +12 -3
  37. package/dist/interaction/operatorPresentation.js +5 -0
  38. package/dist/job/builtinJob.js +58 -0
  39. package/dist/job/durableJob.js +72 -49
  40. package/dist/job/jobDefinition.js +68 -0
  41. package/dist/job/jobRunner.js +2 -1
  42. package/dist/job/projectRefreshRunner.js +48 -0
  43. package/dist/message/globalInboxRecovery.js +44 -0
  44. package/dist/message/globalInterrupt.js +1 -1
  45. package/dist/message/inboxReceipt.js +14 -6
  46. package/dist/message/inboxTurn.js +86 -0
  47. package/dist/message/message.js +11 -0
  48. package/dist/message/taskInboxOwnership.js +20 -6
  49. package/dist/repository/projectRefresh.js +58 -0
  50. package/dist/repository/taskWorkspacePreparer.js +2 -1
  51. package/dist/repository/workspaceCleanupInspection.js +6 -1
  52. package/dist/schedule/schedule.js +108 -0
  53. package/dist/scheduler/operatorEvent.js +2 -0
  54. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  55. package/dist/storage/contextRecords.js +8 -0
  56. package/dist/storage/jobSchema.js +15 -0
  57. package/dist/storage/maintenanceCaller.js +1 -1
  58. package/dist/storage/recordValidation.js +5 -0
  59. package/dist/storage/scheduleSchema.js +12 -0
  60. package/dist/storage/sqliteSchema.js +95 -24
  61. package/dist/storage/sqliteStore.js +123 -10
  62. package/dist/storage/storageVersions.js +1 -1
  63. package/dist/storage/storeRpc.js +5 -0
  64. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  65. package/dist/task/archiveDiagnostics.js +1 -1
  66. package/dist/task/archivePreflight.js +1 -1
  67. package/dist/task/projectDeliveryScope.js +20 -0
  68. package/dist/task/publicationToolObservation.js +126 -0
  69. package/dist/task/remoteDelivery.js +19 -8
  70. package/dist/task/remoteDeliveryService.js +5 -4
  71. package/dist/task/taskActivationService.js +35 -0
  72. package/dist/web/assets/client/app/settings.js +7 -1
  73. package/dist/web/assets/client/i18n/en.js +65 -2
  74. package/dist/web/assets/client/i18n/zh-CN.js +65 -2
  75. package/dist/web/assets/client/index.js +2 -0
  76. package/dist/web/assets/client/views/jobs.js +222 -0
  77. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  78. package/dist/web/assets/client/views/task/page.js +11 -1
  79. package/dist/web/assets/shared/manageNav.js +1 -0
  80. package/dist/web/assets/shared/taskTabs.js +2 -2
  81. package/dist/web/assets/shell/settingsContent.js +3 -0
  82. package/dist/web/assets/styles/index.js +2 -0
  83. package/dist/web/assets/styles/views/jobs.js +19 -0
  84. package/dist/web/controllerWeb.js +73 -21
  85. package/dist/web/taskPreviews.js +21 -13
  86. package/dist/web/webAccess.js +109 -0
  87. package/dist/web/webJobs.js +52 -0
  88. package/dist/web/webServer.js +76 -44
  89. package/docs/architecture/capabilities-and-resources.md +6 -4
  90. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  91. package/docs/cli-information-contract.md +57 -4
  92. package/docs/cli-information-contract.zh-CN.md +41 -2
  93. package/docs/job-runs.md +224 -0
  94. package/docs/project-delivery-scope.md +59 -0
  95. package/docs/release-workflow.md +86 -0
  96. package/docs/release-workflow.zh-CN.md +68 -0
  97. package/docs/schedules.md +132 -0
  98. package/docs/storage-baseline.md +31 -1
  99. package/docs/storage-baseline.zh-CN.md +25 -1
  100. package/docs/task-delivery.md +74 -2
  101. package/docs/task-delivery.zh-CN.md +39 -1
  102. package/docs/testing/verification-levels.md +6 -2
  103. package/docs/testing/verification-levels.zh-CN.md +6 -3
  104. package/i18n/README.zh-CN.md +6 -3
  105. package/package.json +4 -3
  106. package/skills/yui-leader/SKILL.md +44 -1
  107. package/skills/yui-leader/VERSION +1 -1
  108. package/skills/yui-leader/references/planning.md +9 -1
  109. package/skills/yui-operator/SKILL.md +31 -0
  110. package/skills/yui-operator/VERSION +1 -1
  111. package/skills/yui-reviewer/SKILL.md +4 -0
  112. package/skills/yui-reviewer/VERSION +1 -1
  113. package/skills/yui-runtime/SKILL.md +67 -8
  114. package/skills/yui-runtime/VERSION +1 -1
  115. package/skills/yui-runtime/references/context-ordering.md +103 -0
  116. package/skills/yui-runtime/references/publication.md +44 -4
  117. package/skills/yui-runtime/references/recovery.md +21 -0
  118. package/skills/yui-worker/SKILL.md +5 -0
  119. package/skills/yui-worker/VERSION +1 -1
@@ -689,6 +689,7 @@ export class FileTaskController {
689
689
  #onMaintenanceFenceDefer;
690
690
  #leaderWakeFence;
691
691
  #jobSupervisor;
692
+ #schedules;
692
693
  #globalInputDelivery;
693
694
  #providerRetry;
694
695
  #continuationReconciler;
@@ -741,6 +742,7 @@ export class FileTaskController {
741
742
  this.#onMaintenanceFenceDefer = options.onMaintenanceFenceDefer;
742
743
  this.#leaderWakeFence = options.leaderWakeFence;
743
744
  this.#jobSupervisor = options.jobSupervisor;
745
+ this.#schedules = options.schedules;
744
746
  this.#continuationReconciler = options.continuationReconciler;
745
747
  this.#hostTerminalRecovery = options.hostTerminalRecovery;
746
748
  this.#globalInputDelivery = options.globalInputDelivery;
@@ -1014,6 +1016,7 @@ export class FileTaskController {
1014
1016
  // DurableJob reconciliation runs before the scheduler pass so a
1015
1017
  // terminal job's Leader wakeup is enqueued in the same pass that
1016
1018
  // processes Leader wakeups.
1019
+ this.#schedules?.reconcile(this.#now());
1017
1020
  this.#jobSupervisor?.reconcile(this.#now());
1018
1021
  if (scope.kind === "full") {
1019
1022
  result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError);
@@ -1631,6 +1634,38 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1631
1634
  lifecycleRequests.delete(request);
1632
1635
  }
1633
1636
  }
1637
+ if (method === "schedule.control") {
1638
+ if (options.schedules === undefined)
1639
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Schedules unavailable.");
1640
+ try {
1641
+ return jobControlJson(options.schedules.control(params, options.now?.() ?? new Date()));
1642
+ }
1643
+ catch (error) {
1644
+ if (error instanceof Error && error.name === "Error")
1645
+ error.name = "CoreJobError";
1646
+ throw error;
1647
+ }
1648
+ }
1649
+ if (method === "job.definition") {
1650
+ if (options.jobDefinitions === undefined)
1651
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Job definitions unavailable.");
1652
+ let result;
1653
+ try {
1654
+ result = options.jobDefinitions(params, new Date());
1655
+ }
1656
+ catch (error) {
1657
+ if (error instanceof Error && error.name === "Error")
1658
+ error.name = "CoreJobError";
1659
+ throw error;
1660
+ }
1661
+ // The service has validated this scope. Only execution mutations need
1662
+ // reconciliation; definition reads/edits do not start background work.
1663
+ const input = params;
1664
+ if (["run", "run-cancel", "run-acknowledge"].includes(input.op)) {
1665
+ runtime.signal(input.scope.kind === "task" ? `task:${input.scope.taskId}` : "operator");
1666
+ }
1667
+ return jobControlJson(result);
1668
+ }
1634
1669
  if (method === "job.start" || method === "job.get" || method === "job.cancel" || method === "job.acknowledge") {
1635
1670
  const control = options.jobControl;
1636
1671
  if (control === undefined) {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { deliveredMessageWake, pinMessageRevisions, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
3
+ import { nonReplayableMessageWake, pinMessageRevisions, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
4
4
  import { contextSnapshotRef } from "../context/contextSnapshot.js";
5
5
  import { freezeRunContextSnapshot } from "../context/runContextPack.js";
6
6
  import { taskNotificationPresentation } from "../interaction/notificationPresentation.js";
@@ -30,6 +30,7 @@ import { SYSTEM_OPERATOR_ROLE } from "../role/systemRoles.js";
30
30
  import { standardAgentError } from "../runtime/agentError.js";
31
31
  import { recordTaskAgentError } from "./taskAgentError.js";
32
32
  import { recordGlobalRuntimeAttention, recordGlobalRuntimeFailure, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
33
+ import { checkInboxTurn } from "./inboxTurnCheck.js";
33
34
  import { queueOperatorNotification } from "./operatorNotification.js";
34
35
  import { claimOperatorAttention } from "../scheduler/operatorEvent.js";
35
36
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
@@ -466,7 +467,7 @@ export class FileSchedulerStoreAdapter {
466
467
  const outcome = input.kind === "input.accepted" ? "accepted"
467
468
  : input.kind === "input.rejected" ? "rejected" : "delivery-unknown";
468
469
  if (message.control?.outcome !== "accepted" && message.control?.outcome !== "rejected") {
469
- const updated = { ...message, control: { requestId: message.inputControl.requestId,
470
+ const updated = { ...message, control: { ...message.control, requestId: message.inputControl.requestId,
470
471
  receiptId: receipt, outcome, observedAt: at } };
471
472
  store.updateGlobalRoleMessage(outcome === "accepted"
472
473
  ? markGlobalRoleMessageDelivered(updated, new Date(at)) : updated);
@@ -490,6 +491,10 @@ export class FileSchedulerStoreAdapter {
490
491
  }
491
492
  store.saveGlobalRoleSessionSet(updateGlobalRoleProviderRuntime(sessions, binding, now));
492
493
  settleGlobalRetryInput(store, fence.roleName, binding, now);
494
+ if (["turn.completed", "turn.failed", "turn.cancelled"].includes(input.kind)) {
495
+ checkInboxTurn(store, { scope: "global", roleName: fence.roleName, agentId: fence.agentId,
496
+ nativeSessionId: fence.nativeSessionId }, binding.run, now);
497
+ }
493
498
  if (input.kind === "turn.failed" && matches && input.payload.failure !== undefined
494
499
  && !providerRetryPending(binding)) {
495
500
  recordGlobalRuntimeAttention(store, fence.roleName, input.eventId, {
@@ -1157,8 +1162,7 @@ export class FileSchedulerStoreAdapter {
1157
1162
  throw new AgentHostProviderSessionBusyError("The next Global input belongs to an explicit then handoff.");
1158
1163
  }
1159
1164
  if (message !== undefined) {
1160
- if (store.queryGlobalRoleMessages(input.roleName, { handling: true, limit: 0 }).count > 0
1161
- || message.inbox !== undefined || message.delivery !== undefined || message.notDelivered !== undefined
1165
+ if (message.inbox !== undefined || message.delivery !== undefined || message.notDelivered !== undefined
1162
1166
  || message.control !== undefined && message.control.outcome !== "rejected"
1163
1167
  || message.deliveryTarget?.agentId !== input.agentId
1164
1168
  || message.deliveryTarget?.nativeSessionId !== input.nativeSessionId) {
@@ -1535,8 +1539,9 @@ export class FileSchedulerStoreAdapter {
1535
1539
  });
1536
1540
  }
1537
1541
  listActiveDurableJobs() {
1538
- return this.store.listActiveDurableJobs();
1542
+ return [...this.store.listActiveDurableJobs(), ...this.store.listActiveGlobalJobRuns()];
1539
1543
  }
1544
+ getGlobalJobRun(id) { return this.store.getGlobalJobRun(id); }
1540
1545
  /**
1541
1546
  * Apply one durable-job transition and, in the SAME transaction, enqueue
1542
1547
  * the Leader wakeup. A terminal job without its wakeup enqueued is a lost
@@ -1548,10 +1553,24 @@ export class FileSchedulerStoreAdapter {
1548
1553
  */
1549
1554
  transitionDurableJob(taskId, jobId, transition, now, wakeup) {
1550
1555
  return this.store.transaction((store) => {
1556
+ if (taskId === "global") {
1557
+ const current = store.getGlobalJobRun(jobId);
1558
+ if (current === null)
1559
+ return null;
1560
+ const next = transition(current);
1561
+ if ("taskId" in next)
1562
+ throw new Error("A global JobRun cannot change scope.");
1563
+ store.saveGlobalJobRun(next);
1564
+ if (wakeup !== undefined)
1565
+ enqueueWork(store, { kind: "operator" }, "global-job-finished", now, [{ type: "global-job-run", id: jobId }], { source: "global-job", dedupeKey: `operator-global-job-run:${jobId}` });
1566
+ return next;
1567
+ }
1551
1568
  const current = store.getDurableJob(taskId, jobId);
1552
1569
  if (current === null)
1553
1570
  return null;
1554
1571
  const next = transition(current);
1572
+ if (!("taskId" in next))
1573
+ throw new Error("A Task JobRun cannot change scope.");
1555
1574
  store.saveDurableJob(taskId, next);
1556
1575
  if (wakeup !== undefined) {
1557
1576
  enqueueWork(store, { kind: "role", taskId, roleName: "leader" }, wakeup.reason, now, [...wakeup.refs]);
@@ -1672,12 +1691,24 @@ export class FileSchedulerStoreAdapter {
1672
1691
  store.saveTaskRoleSessionSet(selectNewTaskRoleSession(set, set.activeAgentId, now));
1673
1692
  const event = createTaskEvent(store.nextEventId(owner.taskId), owner.taskId, "runtime.session-replaced", { roleName: owner.roleName }, now);
1674
1693
  store.saveEvent(owner.taskId, event);
1675
- // Accepted notification batches are already consumed; unresolved old
1676
- // claims belong to the discarded runtime, not to its successor.
1694
+ // Release only operational occupancy after exact runtime stop. Neither
1695
+ // an uncertain automatic submission nor an Agent's handling claim may
1696
+ // be replayed as input to the replacement.
1677
1697
  const leaderTarget = { kind: "role", taskId: owner.taskId, roleName: "leader" };
1678
1698
  const leaderMailbox = store.getWorkMailbox(leaderTarget);
1679
- if (owner.roleName === "leader" && leaderMailbox?.processing?.owner.startsWith("leader-notification:")) {
1680
- store.saveWorkMailbox(releaseProcessing(leaderMailbox, leaderMailbox.processing.batchId));
1699
+ if (owner.roleName === "leader" && leaderMailbox?.processing != null) {
1700
+ const processing = leaderMailbox.processing;
1701
+ if (processing.owner.startsWith("leader-notification:")) {
1702
+ store.saveEvent(owner.taskId, createTaskEvent(store.nextEventId(owner.taskId), owner.taskId, "notification.resolved", {
1703
+ wakeId: processing.owner.slice("leader-notification:".length), attemptId: processing.batchId,
1704
+ reason: "Session replacement after verified native/resource stop; inspect original effects.",
1705
+ outcome: "released-without-replay"
1706
+ }, now));
1707
+ store.saveWorkMailbox(completeProcessing(leaderMailbox, processing.batchId));
1708
+ }
1709
+ else if (processing.owner.startsWith("agent-inbox:")) {
1710
+ store.saveWorkMailbox(completeProcessing(leaderMailbox, processing.batchId));
1711
+ }
1681
1712
  }
1682
1713
  enqueueWork(store, leaderTarget, "session-replaced", now, [{ type: "event", taskId: owner.taskId, id: event.id }]);
1683
1714
  }
@@ -1824,6 +1855,24 @@ export class FileSchedulerStoreAdapter {
1824
1855
  if (mailbox === null)
1825
1856
  return null;
1826
1857
  const provider = store.getTaskRoleSessionSet(taskId, "leader")?.providerBinding?.run;
1858
+ // Only a committed lifecycle event opens this initialization path, not a
1859
+ // caller-supplied reason string. Consume that event once, separately from
1860
+ // business input. It exists only after exact-owner cleanup completed.
1861
+ const recoveryRefs = mailbox.pending?.reasons.includes("session-replaced")
1862
+ ? mailbox.pending.refs.filter(ref => {
1863
+ if (ref.type !== "event" || ref.taskId !== taskId)
1864
+ return false;
1865
+ const event = store.queryContextRecords(taskId, { family: "task-event", ids: [ref.id],
1866
+ eventTypes: ["runtime.session-replaced"], limit: 1 }).records[0]?.value;
1867
+ return event?.payload.roleName === "leader";
1868
+ }) : [];
1869
+ // Valid earlier storage may retain an actively claimed mailbox even
1870
+ // after replacement completed. Its durable wake/claim remains untouched.
1871
+ if (recoveryRefs.length > 0 && mailbox.processing?.owner.startsWith("agent-inbox:")
1872
+ && !(provider && ["submitting", "accepted", "delivery-unknown"].includes(provider.status))) {
1873
+ mailbox = completeProcessing(mailbox, mailbox.processing.batchId);
1874
+ store.saveWorkMailbox(mailbox);
1875
+ }
1827
1876
  const processing = mailbox.processing;
1828
1877
  if (processing !== null) {
1829
1878
  if (!processing.owner.startsWith("leader-notification:"))
@@ -1867,7 +1916,7 @@ export class FileSchedulerStoreAdapter {
1867
1916
  return { wakeId, attemptId: nextAttempt, disposition: "submit",
1868
1917
  presentation: taskNotificationPresentation(wake, store) };
1869
1918
  }
1870
- if (unfinishedLeaderInbox(store, taskId))
1919
+ if (unfinishedLeaderInbox(store, taskId) && recoveryRefs.length === 0)
1871
1920
  return null;
1872
1921
  if (mailbox.pending === null || (provider !== null && provider !== undefined
1873
1922
  && ["submitting", "accepted", "delivery-unknown"].includes(provider.status)))
@@ -1880,7 +1929,7 @@ export class FileSchedulerStoreAdapter {
1880
1929
  const obsoleteIds = new Set();
1881
1930
  for (const message of store.listMessages(taskId)) {
1882
1931
  if (pendingMessageIds.has(message.id) && (taskMessageInboxReceipt(store, message) !== undefined
1883
- || deliveredMessageWake(store, message) !== undefined)) {
1932
+ || nonReplayableMessageWake(store, message) !== undefined)) {
1884
1933
  obsoleteIds.add(message.id);
1885
1934
  }
1886
1935
  }
@@ -1915,21 +1964,23 @@ export class FileSchedulerStoreAdapter {
1915
1964
  const handoffs = claimedMessages.filter(message => !obsoleteIds.has(message.id));
1916
1965
  // A quiet/new Session is not evidence that the claimed old Turn stopped.
1917
1966
  // Keep the exact pending intent until the original fence is provable.
1918
- if (handoffs.some((message) => interruptThenTerminalState(store, message) !== "ready"))
1967
+ if (recoveryRefs.length === 0
1968
+ && handoffs.some((message) => interruptThenTerminalState(store, message) !== "ready"))
1919
1969
  return null;
1920
1970
  const handoffIds = new Set(handoffs.map((message) => message.id));
1921
1971
  if (availableRefs.length === 0 && obsoleteIds.size > 0) {
1922
1972
  store.saveWorkMailbox(consumePendingBatch(mailbox, pending));
1923
1973
  return null;
1924
1974
  }
1925
- const selectedRefs = handoffs.length === 0 ? availableRefs
1975
+ const selectedRefs = recoveryRefs.length > 0 ? recoveryRefs : handoffs.length === 0 ? availableRefs
1926
1976
  : availableRefs.filter((ref) => ref.type === "message" && handoffIds.has(ref.id));
1927
- const remainingRefs = handoffs.length === 0 ? []
1928
- : availableRefs.filter((ref) => !(ref.type === "message" && handoffIds.has(ref.id)));
1977
+ const remainingRefs = recoveryRefs.length > 0 ? availableRefs.filter(ref => !recoveryRefs.includes(ref))
1978
+ : handoffs.length === 0 ? []
1979
+ : availableRefs.filter((ref) => !(ref.type === "message" && handoffIds.has(ref.id)));
1929
1980
  const latest = latestTaskWake(store.listTaskWakes(taskId));
1930
1981
  const wakeId = store.nextTaskWakeId(taskId);
1931
1982
  const wake = createTaskWake({
1932
- id: wakeId, taskId, reasons: mailbox.pending.reasons,
1983
+ id: wakeId, taskId, reasons: recoveryRefs.length > 0 ? ["session-replaced"] : mailbox.pending.reasons,
1933
1984
  refs: pinMessageRevisions(store, taskId, selectedRefs),
1934
1985
  fromCursor: latest?.toCursor ?? task.createdAt,
1935
1986
  toCursor: new Date(Math.max(now.getTime(), Date.parse(mailbox.pending.lastQueuedAt))).toISOString(), now
@@ -2216,6 +2267,8 @@ export class FileSchedulerStoreAdapter {
2216
2267
  }
2217
2268
  sessions = settleStructuredProviderTurn(sessions, canonicalRunId, input.providerStatus, now, attemptId);
2218
2269
  store.saveTaskRoleSessionSet(sessions);
2270
+ checkInboxTurn(store, { scope: "task", taskId: input.taskId, roleName: input.roleName,
2271
+ agentId: input.agentId, nativeSessionId: input.nativeSessionId }, sessions.providerBinding?.run, now);
2219
2272
  }
2220
2273
  if (input.observation !== undefined)
2221
2274
  this.persistRuntimeObservation(input.observation, now);
@@ -2321,6 +2374,8 @@ export class FileSchedulerStoreAdapter {
2321
2374
  }
2322
2375
  if (input.observation !== undefined)
2323
2376
  this.persistRuntimeObservation(input.observation, now);
2377
+ checkInboxTurn(store, { scope: "task", taskId: input.taskId, roleName: input.roleName,
2378
+ agentId: input.agentId, nativeSessionId: input.nativeSessionId }, sessions.providerBinding?.run, now);
2324
2379
  return {
2325
2380
  session: sessions.sessions[input.agentId],
2326
2381
  duplicate: false,
@@ -2807,6 +2862,10 @@ export class FileSchedulerStoreAdapter {
2807
2862
  // durable native terminal recorded just above remains the proof of record.
2808
2863
  current = settleGlobalRoleRuntimeTurn(current, input.nativeTurnId, input.providerStatus, now);
2809
2864
  store.saveGlobalRoleSessionSet(current);
2865
+ if (current.providerBinding?.run?.nativeTurnId === input.nativeTurnId) {
2866
+ checkInboxTurn(store, { scope: "global", roleName: input.roleName,
2867
+ agentId: input.agentId, nativeSessionId }, current.providerBinding.run, now);
2868
+ }
2810
2869
  if (input.roleName === SYSTEM_OPERATOR_ROLE
2811
2870
  && input.adapterId === "codex"
2812
2871
  && completedStatus === "active") {
@@ -6,11 +6,12 @@ import { providerRetryPending } from "../runtime/providerRetry.js";
6
6
  import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
7
7
  import { globalMessagePresentation } from "../interaction/notificationPresentation.js";
8
8
  import { recordGlobalRuntimeAttention, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
9
+ import { globalInboxRecovery } from "../message/globalInboxRecovery.js";
9
10
  /** Messages are the only queue. Host's atomic begin arbitrates native writers. */
10
11
  export async function deliverGlobalInputs(home, store, ensure, onError, options = {}) {
11
12
  for (const role of store.listGlobalRoles()) {
12
13
  try {
13
- let pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
14
+ let pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
14
15
  if (hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: role.name }))))
15
16
  continue;
16
17
  let sessions = store.getGlobalRoleSessionSet(role.name);
@@ -20,7 +21,7 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
20
21
  continue;
21
22
  if (sessions?.providerBinding?.retry !== undefined && !providerRetryPending(sessions.providerBinding)) {
22
23
  store.transaction(tx => settleGlobalRetryInput(tx, role.name, tx.getGlobalRoleSessionSet(role.name)?.providerBinding, new Date()));
23
- pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
24
+ pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
24
25
  if (pending.length === 0)
25
26
  continue;
26
27
  }
@@ -84,12 +85,13 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
84
85
  || providerRetryPending(sessions?.providerBinding)
85
86
  || hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: role.name }))))
86
87
  continue;
87
- pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
88
+ pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
88
89
  if (pending.length === 0)
89
90
  continue;
90
91
  }
91
- if (store.queryGlobalRoleMessages(role.name, { handling: true, limit: 0 }).count > 0)
92
- continue;
92
+ const recovery = globalInboxRecovery(store, role.name, options.now ?? new Date());
93
+ const recovering = recovery !== undefined && recovery.inbox === undefined && recovery.delivery === undefined
94
+ && recovery.notDelivered === undefined && recovery.control === undefined;
93
95
  // Interrupt-then reserves the next legal slot even behind a large queue.
94
96
  const reservedPage = store.queryGlobalRoleMessages(role.name, {
95
97
  pending: true, interruptThen: true, limit: 64
@@ -106,7 +108,7 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
106
108
  store.updateGlobalRoleMessage(markGlobalRoleMessageNotDelivered(message, "native-session-or-authority-changed", new Date()));
107
109
  const obsoleteIds = new Set(obsolete.map(message => message.id));
108
110
  pending = pending.filter(message => !obsoleteIds.has(message.id));
109
- if (pending.length === 0)
111
+ if (pending.length === 0 && !recovering)
110
112
  continue;
111
113
  if (reservedPage.count > reserved.length && obsolete.length === reserved.length)
112
114
  continue;
@@ -117,7 +119,15 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
117
119
  && sessions.sessions[message.deliveryTarget.agentId]?.nativeSessionId === message.deliveryTarget.nativeSessionId;
118
120
  const claims = reserved.filter(message => !obsolete.includes(message));
119
121
  let next;
120
- if (claims.length > 0) {
122
+ if (recovering) {
123
+ // The notice carries no original business input. An obsolete then
124
+ // target is never inherited; a current explicit reservation still
125
+ // owns its slot. Host admission checks this again atomically.
126
+ if (claims.length > 0)
127
+ continue;
128
+ next = recovery;
129
+ }
130
+ else if (claims.length > 0) {
121
131
  next = claims.find(message => {
122
132
  const claim = message.interruptThen;
123
133
  const binding = sessions?.providerBinding;
@@ -0,0 +1,64 @@
1
+ import { createGlobalRoleMessage } from "../message/message.js";
2
+ import { INBOX_CHECK_PREFIX, INBOX_CHECK_REASON, missingTurnHandling } from "../message/inboxTurn.js";
3
+ import { readDigest } from "../output/boundedRead.js";
4
+ import { createTaskEvent } from "../event/taskEvent.js";
5
+ import { enqueueWork } from "../coordination/workMailboxQueue.js";
6
+ import { retiredTaskRecordIds } from "../task/taskRecordRetirement.js";
7
+ import { completeProcessing } from "../coordination/workMailbox.js";
8
+ /** Called inside exact-terminal persistence's transaction. A terminal checks
9
+ * receipts; it cannot infer a disposition. One existing notification per exact
10
+ * turn, no timer, retry queue, automatic ACK or reminder-of-reminder loop. */
11
+ export function checkInboxTurn(store, actor, turn, now) {
12
+ if (!turn || !["completed", "failed", "cancelled"].includes(turn.status))
13
+ return;
14
+ const retired = actor.scope === "task" ? retiredTaskRecordIds(store.listEvents(actor.taskId), "message") : new Set();
15
+ const entries = actor.scope === "global" ? store.listGlobalRoleMessages(actor.roleName)
16
+ : [...(actor.roleName === "leader" ? store.listTaskWakes(actor.taskId) : []),
17
+ ...store.listMessages(actor.taskId).filter(message => !retired.has(message.id))];
18
+ const missing = missingTurnHandling(entries, actor, turn);
19
+ if (actor.scope === "task" && actor.roleName === "leader") {
20
+ const mailbox = store.getWorkMailbox({ kind: "role", taskId: actor.taskId, roleName: "leader" });
21
+ // End the exact active-consumption notification reservation only. Its
22
+ // original claim remains unfinished and discoverable; no writer is freed
23
+ // here (the caller already persisted an exact native terminal).
24
+ if (mailbox?.processing && entries.some(entry => entry.inbox?.claim.actor.attemptId === turn.attemptId
25
+ && entry.inbox.claim.actor.nativeSessionId === actor.nativeSessionId
26
+ && entry.inbox.claim.actor.agentId === actor.agentId
27
+ && mailbox.processing?.owner === `agent-inbox:${entry.id}`
28
+ && mailbox.processing.batchId === `inbox:${entry.id}`)) {
29
+ store.saveWorkMailbox(completeProcessing(mailbox, mailbox.processing.batchId));
30
+ }
31
+ }
32
+ if (!missing.length)
33
+ return;
34
+ const requestId = INBOX_CHECK_PREFIX + readDigest([actor.scope, actor.taskId, actor.roleName,
35
+ actor.agentId, actor.nativeSessionId, turn.attemptId]);
36
+ const facts = {
37
+ roleName: actor.roleName, nativeSessionId: actor.nativeSessionId, attemptId: turn.attemptId,
38
+ status: turn.status, count: String(missing.length), entries: missing.slice(0, 16).map(entry => entry.id).join(","),
39
+ read: actor.scope === "global" || actor.roleName === "leader" ? "yui inbox list --view history --json"
40
+ : `yui task message show ${actor.taskId}/${missing[0].id} --json`,
41
+ disposition: "Missing handling evidence, not unexecuted work. Read originals and prior effects; explicitly ack or retire within authority. Never replay external effects."
42
+ };
43
+ if (actor.scope === "global") {
44
+ if (store.queryGlobalRoleMessages(actor.roleName, { requestId, limit: 1 }).count)
45
+ return;
46
+ const notice = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), actor.roleName, `Yui-Summary: ${missing.length} input(s) lack handling receipts after an exact native terminal.\n`
47
+ + JSON.stringify(facts), "system", { type: "system" }, now, { inputControl: { action: "queue", requestId } });
48
+ // Retain the original target: replacing a Session is not permission to
49
+ // replay this reminder elsewhere. Successors can inspect history explicitly.
50
+ store.saveGlobalRoleMessage({ ...notice,
51
+ deliveryTarget: { agentId: actor.agentId, nativeSessionId: actor.nativeSessionId } });
52
+ }
53
+ else {
54
+ if (store.listEvents(actor.taskId).some(event => event.type === INBOX_CHECK_REASON
55
+ && event.payload.requestId === requestId))
56
+ return;
57
+ const event = createTaskEvent(store.nextEventId(actor.taskId), actor.taskId, INBOX_CHECK_REASON, { ...facts, requestId }, now);
58
+ store.saveEvent(actor.taskId, event);
59
+ const task = store.getTask(actor.taskId);
60
+ if (task && ["active", "draft"].includes(task.status) && task.executionGate.state === "enabled") {
61
+ enqueueWork(store, { kind: "role", taskId: actor.taskId, roleName: "leader" }, INBOX_CHECK_REASON, now, [{ type: "event", taskId: actor.taskId, id: event.id }], { source: "inbox-check", dedupeKey: requestId });
62
+ }
63
+ }
64
+ }
@@ -9,7 +9,7 @@
9
9
  * preserves its admitted Job across authorized Session replacement.
10
10
  */
11
11
  import { execSync } from "node:child_process";
12
- import { createHash } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import { resolve, sep } from "node:path";
14
14
  import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
15
15
  import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
@@ -21,6 +21,7 @@ import { assertJobAssignmentScope, JobAssignmentScopeError } from "../job/jobAss
21
21
  import { assertContextRecordReadable } from "../context/taskContext.js";
22
22
  import { assertTaskProjectWriteAuthority } from "../task/taskAuthority.js";
23
23
  import { validateHttpPreview } from "../job/httpPreview.js";
24
+ import { definitionFromTaskRun } from "../job/jobDefinition.js";
24
25
  export function createDurableJobControl(store) {
25
26
  const authority = createJobCallAuthority(store);
26
27
  return {
@@ -34,7 +35,7 @@ export function createDurableJobControl(store) {
34
35
  requireJobAssignment(tx, params, params.caller);
35
36
  assertNonSecretJobInput(params);
36
37
  validateHttpPreview(params.env, params.steps);
37
- const baseKey = durableJobIdempotencyKey({
38
+ const specificationKey = durableJobIdempotencyKey({
38
39
  owner: params.owner,
39
40
  projectId: params.projectId,
40
41
  head: params.head,
@@ -42,6 +43,12 @@ export function createDurableJobControl(store) {
42
43
  workspace: params.workspace,
43
44
  env: params.env
44
45
  });
46
+ const definition = params.definitionId === undefined ? null
47
+ : tx.getJobDefinition({ kind: "task", taskId: params.taskId }, params.definitionId);
48
+ if (params.definitionId !== undefined && definition === null)
49
+ throw jobDomainError("Job definition not found.");
50
+ const baseKey = definition === null ? specificationKey : createHash("sha256")
51
+ .update(JSON.stringify([specificationKey, definition.id, definition.revision, params.trigger])).digest("hex");
45
52
  const inputDigest = params.retryOf === undefined
46
53
  ? baseKey
47
54
  : retryDurableJobIdempotencyKey(baseKey, params.retryOf);
@@ -77,9 +84,29 @@ export function createDurableJobControl(store) {
77
84
  }
78
85
  authority.authorize(context, params.taskId);
79
86
  validateStartParams(tx, params);
87
+ const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
88
+ if (previous !== null) {
89
+ requireJobAssignment(tx, previous, params.caller);
90
+ if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
91
+ throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
92
+ }
93
+ }
94
+ const definitionId = definition?.id ?? previous?.definitionId;
95
+ if (definitionId !== undefined) {
96
+ const active = tx.listDurableJobs(params.taskId).find(run => run.definitionId === definitionId
97
+ && (run.status === "queued" || run.status === "running" || run.status === "unknown-needs-attention"));
98
+ if (active !== undefined)
99
+ throw jobDomainError(`Job is already occupied by Run ${active.id} (${active.status}); no new Run was admitted.`);
100
+ }
80
101
  const id = tx.nextDurableJobId(params.taskId);
81
102
  const job = createDurableJob({
82
103
  id,
104
+ definitionId: definitionId ?? `definition-${randomUUID()}`,
105
+ definitionRevision: definition?.revision ?? previous?.definitionRevision,
106
+ trigger: params.trigger ?? {
107
+ source: params.retryOf !== undefined ? "retry" : params.owner.kind === "integration-attempt" ? "integration" : "manual",
108
+ key: requestId
109
+ },
83
110
  taskId: params.taskId,
84
111
  owner: params.owner,
85
112
  projectId: params.projectId,
@@ -94,6 +121,9 @@ export function createDurableJobControl(store) {
94
121
  artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
95
122
  ...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
96
123
  }, now);
124
+ if (tx.getJobDefinition({ kind: "task", taskId: params.taskId }, job.definitionId) === null) {
125
+ tx.saveJobDefinition(definitionFromTaskRun(job));
126
+ }
97
127
  tx.saveDurableJob(params.taskId, job);
98
128
  return { job, created: true };
99
129
  });
@@ -191,7 +221,7 @@ function assertLeaderJobWriteScope(store, target, caller) {
191
221
  throw jobControlError("UNAUTHORIZED", error instanceof Error ? error.message : String(error));
192
222
  }
193
223
  }
194
- function requireJobAssignment(store, target, caller) {
224
+ export function requireJobAssignment(store, target, caller) {
195
225
  try {
196
226
  assertJobAssignmentScope(store, target, caller);
197
227
  }
@@ -201,7 +231,7 @@ function requireJobAssignment(store, target, caller) {
201
231
  throw error;
202
232
  }
203
233
  }
204
- function jobAuthorityBinding(store, scope, roleName, taskId) {
234
+ export function jobAuthorityBinding(store, scope, roleName, taskId) {
205
235
  // A Host detach/reattach preserves the native Session and its queued work.
206
236
  // Authenticate the live launch at ingress, but bind accepted Jobs to the
207
237
  // caller's durable Session identity, not its disposable Host generation.
@@ -232,7 +262,7 @@ function jobAuthorityBinding(store, scope, roleName, taskId) {
232
262
  role.activeAgentId, session.agentId, session.nativeSessionId
233
263
  ])).digest("hex");
234
264
  }
235
- function assertNonSecretJobInput(params) {
265
+ export function assertNonSecretJobInput(params) {
236
266
  // Existing Jobs persist commands and environments. This boundary accepts
237
267
  // non-secret executable specifications only; credential resolution is not a
238
268
  // Job feature. Never hash a known secret then call the digest sanitized.
@@ -263,7 +293,7 @@ function assertNonSecretJobInput(params) {
263
293
  * workspace was not the stable checkout. Now it binds the job to the exact
264
294
  * managed workspace, verifies write access, and requires an active Task.
265
295
  */
266
- function validateStartParams(store, params) {
296
+ export function validateStartParams(store, params) {
267
297
  validateJobTarget(store, params);
268
298
  assertCallerAuthorized(store, params.caller, params.taskId);
269
299
  assertLeaderJobWriteScope(store, params, params.caller);
@@ -384,7 +414,7 @@ function validateJobTarget(store, params) {
384
414
  * identity contract does not defend against another process with the same
385
415
  * user's filesystem access. Bare user calls cannot start or cancel Jobs.
386
416
  */
387
- function assertCallerAuthorized(store, caller, taskId) {
417
+ export function assertCallerAuthorized(store, caller, taskId) {
388
418
  if (caller.scope === "user") {
389
419
  // rr13: A user-scope caller has no per-Session channel binding. Every
390
420
  // durable-state claim it could carry is replayable
@@ -508,7 +538,7 @@ export function parseDurableJobStartParams(value) {
508
538
  const retryOf = record.retryOf === undefined
509
539
  ? undefined
510
540
  : requiredId(record.retryOf, "job.start retryOf");
511
- const caller = parseCaller(record.caller);
541
+ const caller = parseDurableJobCaller(record.caller);
512
542
  return {
513
543
  taskId,
514
544
  owner,
@@ -531,7 +561,7 @@ export function parseDurableJobRefParams(value) {
531
561
  return {
532
562
  taskId: requiredId(record.taskId, "DurableJob taskId"),
533
563
  jobId: requiredId(record.jobId, "DurableJob jobId"),
534
- caller: parseCaller(record.caller)
564
+ caller: parseDurableJobCaller(record.caller)
535
565
  };
536
566
  }
537
567
  /**
@@ -548,7 +578,7 @@ export function parseDurableJobCancelParams(value) {
548
578
  return {
549
579
  taskId: requiredId(record.taskId, "DurableJob taskId"),
550
580
  jobId: requiredId(record.jobId, "DurableJob jobId"),
551
- caller: parseCaller(record.caller)
581
+ caller: parseDurableJobCaller(record.caller)
552
582
  };
553
583
  }
554
584
  /**
@@ -564,7 +594,7 @@ export function parseDurableJobAcknowledgeParams(value) {
564
594
  return {
565
595
  taskId: requiredId(record.taskId, "DurableJob taskId"),
566
596
  jobId: requiredId(record.jobId, "DurableJob jobId"),
567
- caller: parseCaller(record.caller)
597
+ caller: parseDurableJobCaller(record.caller)
568
598
  };
569
599
  }
570
600
  function parseJobOwner(value) {
@@ -645,7 +675,7 @@ function parseSteps(value) {
645
675
  * optional managed Session identity fields. The identity is verified against
646
676
  * durable state by `assertCallerAuthorized`; parsing only validates the JSON shape.
647
677
  */
648
- function parseCaller(value) {
678
+ export function parseDurableJobCaller(value) {
649
679
  if (value === undefined) {
650
680
  throw jobControlError("INVALID_PARAMS", "job caller is required.");
651
681
  }