@zq-silk/yui 2.9.0 → 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 (65) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +21 -9
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/commands/globalRoleCommands.js +30 -9
  5. package/dist/commands/inboxCommands.js +79 -22
  6. package/dist/commands/taskCommands.js +6 -1
  7. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  8. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  9. package/dist/context/runContextPack.js +1 -0
  10. package/dist/context/runInputContract.js +1 -0
  11. package/dist/context/taskContext.js +1 -1
  12. package/dist/context/taskPhase.js +1 -1
  13. package/dist/controlPlaneCli.js +32 -17
  14. package/dist/controller/fileSchedulerStoreAdapter.js +59 -15
  15. package/dist/controller/globalInputDelivery.js +17 -7
  16. package/dist/controller/inboxTurnCheck.js +64 -0
  17. package/dist/controller/providerRetryAdmission.js +2 -0
  18. package/dist/external/gitlabPublicationVerifier.js +10 -28
  19. package/dist/grant/taskAuthorization.js +7 -2
  20. package/dist/interaction/notificationPresentation.js +9 -0
  21. package/dist/message/globalInboxRecovery.js +44 -0
  22. package/dist/message/globalInterrupt.js +1 -1
  23. package/dist/message/inboxReceipt.js +14 -6
  24. package/dist/message/inboxTurn.js +86 -0
  25. package/dist/message/message.js +11 -0
  26. package/dist/message/taskInboxOwnership.js +20 -6
  27. package/dist/repository/workspaceCleanupInspection.js +6 -1
  28. package/dist/storage/sqliteSchema.js +21 -0
  29. package/dist/storage/sqliteStore.js +13 -1
  30. package/dist/storage/storageVersions.js +1 -1
  31. package/dist/task/archiveDiagnostics.js +1 -1
  32. package/dist/task/archivePreflight.js +1 -1
  33. package/dist/task/projectDeliveryScope.js +20 -0
  34. package/dist/task/publicationToolObservation.js +4 -6
  35. package/dist/task/remoteDelivery.js +19 -8
  36. package/dist/task/remoteDeliveryService.js +5 -4
  37. package/dist/web/assets/client/i18n/en.js +4 -2
  38. package/dist/web/assets/client/i18n/zh-CN.js +4 -2
  39. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  40. package/dist/web/controllerWeb.js +73 -21
  41. package/dist/web/taskPreviews.js +21 -13
  42. package/dist/web/webAccess.js +109 -0
  43. package/dist/web/webServer.js +45 -44
  44. package/docs/architecture/capabilities-and-resources.md +6 -4
  45. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  46. package/docs/cli-information-contract.md +14 -2
  47. package/docs/cli-information-contract.zh-CN.md +9 -1
  48. package/docs/project-delivery-scope.md +59 -0
  49. package/docs/release-workflow.md +43 -0
  50. package/docs/release-workflow.zh-CN.md +35 -0
  51. package/docs/storage-baseline.md +14 -1
  52. package/docs/storage-baseline.zh-CN.md +11 -1
  53. package/docs/task-delivery.md +8 -2
  54. package/docs/task-delivery.zh-CN.md +6 -0
  55. package/docs/testing/verification-levels.md +2 -2
  56. package/docs/testing/verification-levels.zh-CN.md +2 -2
  57. package/i18n/README.zh-CN.md +6 -3
  58. package/package.json +1 -1
  59. package/skills/yui-leader/SKILL.md +26 -0
  60. package/skills/yui-leader/VERSION +1 -1
  61. package/skills/yui-operator/SKILL.md +31 -0
  62. package/skills/yui-operator/VERSION +1 -1
  63. package/skills/yui-runtime/SKILL.md +58 -5
  64. package/skills/yui-runtime/VERSION +1 -1
  65. package/skills/yui-runtime/references/recovery.md +21 -0
@@ -168,8 +168,6 @@ export async function main() {
168
168
  const discovery = discoveryCommandTree(process.env);
169
169
  invocationHome = home;
170
170
  const routedForFence = args.length === 0 ? undefined : routeInvocation(args);
171
- const managedInvocation = process.env.YUI_SESSION_SCOPE === "task"
172
- || process.env.YUI_SESSION_SCOPE === "global";
173
171
  const homeFreeInvocation = args.length === 0
174
172
  || (args[0] === "config" && (args[1] === "describe"
175
173
  || (args[1] === "completion" && args[2] === "candidates")))
@@ -679,29 +677,46 @@ export async function main() {
679
677
  });
680
678
  const workspaceCoordinator = new TaskWorkspaceCoordinator(store, workspacePreparer, runtime);
681
679
  if (resolved[0] === "web") {
682
- if (managedInvocation || process.env.YUI_ROLE || process.env.YUI_NATIVE_SESSION_ID) {
683
- throw usageError("The Web user ingress must be started from a local user terminal, not a managed Session.");
684
- }
680
+ if (hasManagedIdentity(process.env)) {
681
+ const current = requireManagedCaller(store, process.env);
682
+ if (current.scope !== "global" || current.roleName !== "operator") {
683
+ throw usageError("Only a local user or the current global Operator may manage Web listeners.");
684
+ }
685
+ }
686
+ const caller = resolveJobCaller(process.env, "");
687
+ const describe = (status) => status
688
+ ? `Web listener: ${status.state}\n`
689
+ + (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
690
+ + (status.authentication === "basic" ? "Browser authentication: username yui; use the password from your private credential file.\n" : "")
691
+ + (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
692
+ + (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
693
+ : "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
685
694
  if (resolved.length === 2 && (resolved[1] === "--status" || resolved[1] === "--stop")) {
686
- const status = await callController(home, "web.status", {});
695
+ const status = await callController(home, "web.status", { caller });
687
696
  if (resolved[1] === "--status")
688
- emit(status ? `Yui web control room: ${status.url}\n` : "Web is not running.\n", false, status);
697
+ emit(describe(status), false, status);
689
698
  else {
690
- if (status)
691
- await callController(home, "web.stop", { id: status.id });
692
- emit("Web listener stopped; Controller and Agents are unchanged.\n");
699
+ if (status && !status.id)
700
+ throw usageError("Web listener is starting; query status before stopping.");
701
+ const result = status ? await callController(home, "web.stop", { id: status.id, caller }) : { stopped: false };
702
+ emit("Web listener stopped or already absent; Controller and Agents are unchanged.\n", false, result);
693
703
  }
694
704
  return;
695
705
  }
696
- if (jsonOutput)
697
- throw usageError("Web start does not support --json; use --status.");
698
- const options = parseWebCommandOptions(resolved.slice(1));
706
+ const detached = jsonOutput || caller.scope === "global" || resolved.includes("--detach");
707
+ const options = parseWebCommandOptions(resolved.slice(1).filter(arg => arg !== "--detach"));
699
708
  const id = randomUUID();
700
- await callController(home, "web.start", { ...options, id });
701
- const displayHost = options.host === "::1" ? "[::1]" : options.host;
702
- process.stdout.write(`Yui web control room: http://${displayHost}:${options.port}\n`);
709
+ const status = await callController(home, "web.start", { ...options, id, caller });
710
+ emit(describe(status), false, status);
711
+ if (detached || status.alreadyRunning)
712
+ return;
703
713
  await new Promise((resolve) => {
714
+ // The Controller owns the listening socket, so this CLI otherwise has
715
+ // no referenced handle: a Promise/signal handler alone does not keep
716
+ // Node alive for the foreground user's Ctrl-C.
717
+ const keepAlive = setInterval(() => { }, 60_000);
704
718
  const stop = () => {
719
+ clearInterval(keepAlive);
705
720
  process.off("SIGINT", stop);
706
721
  process.off("SIGTERM", stop);
707
722
  resolve();
@@ -709,7 +724,7 @@ export async function main() {
709
724
  process.once("SIGINT", stop);
710
725
  process.once("SIGTERM", stop);
711
726
  });
712
- await callController(home, "web.stop", { id });
727
+ await callController(home, "web.stop", { id, caller });
713
728
  return;
714
729
  }
715
730
  if (resolved[0] === "config") {
@@ -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) {
@@ -1687,12 +1691,24 @@ export class FileSchedulerStoreAdapter {
1687
1691
  store.saveTaskRoleSessionSet(selectNewTaskRoleSession(set, set.activeAgentId, now));
1688
1692
  const event = createTaskEvent(store.nextEventId(owner.taskId), owner.taskId, "runtime.session-replaced", { roleName: owner.roleName }, now);
1689
1693
  store.saveEvent(owner.taskId, event);
1690
- // Accepted notification batches are already consumed; unresolved old
1691
- // 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.
1692
1697
  const leaderTarget = { kind: "role", taskId: owner.taskId, roleName: "leader" };
1693
1698
  const leaderMailbox = store.getWorkMailbox(leaderTarget);
1694
- if (owner.roleName === "leader" && leaderMailbox?.processing?.owner.startsWith("leader-notification:")) {
1695
- 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
+ }
1696
1712
  }
1697
1713
  enqueueWork(store, leaderTarget, "session-replaced", now, [{ type: "event", taskId: owner.taskId, id: event.id }]);
1698
1714
  }
@@ -1839,6 +1855,24 @@ export class FileSchedulerStoreAdapter {
1839
1855
  if (mailbox === null)
1840
1856
  return null;
1841
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
+ }
1842
1876
  const processing = mailbox.processing;
1843
1877
  if (processing !== null) {
1844
1878
  if (!processing.owner.startsWith("leader-notification:"))
@@ -1882,7 +1916,7 @@ export class FileSchedulerStoreAdapter {
1882
1916
  return { wakeId, attemptId: nextAttempt, disposition: "submit",
1883
1917
  presentation: taskNotificationPresentation(wake, store) };
1884
1918
  }
1885
- if (unfinishedLeaderInbox(store, taskId))
1919
+ if (unfinishedLeaderInbox(store, taskId) && recoveryRefs.length === 0)
1886
1920
  return null;
1887
1921
  if (mailbox.pending === null || (provider !== null && provider !== undefined
1888
1922
  && ["submitting", "accepted", "delivery-unknown"].includes(provider.status)))
@@ -1895,7 +1929,7 @@ export class FileSchedulerStoreAdapter {
1895
1929
  const obsoleteIds = new Set();
1896
1930
  for (const message of store.listMessages(taskId)) {
1897
1931
  if (pendingMessageIds.has(message.id) && (taskMessageInboxReceipt(store, message) !== undefined
1898
- || deliveredMessageWake(store, message) !== undefined)) {
1932
+ || nonReplayableMessageWake(store, message) !== undefined)) {
1899
1933
  obsoleteIds.add(message.id);
1900
1934
  }
1901
1935
  }
@@ -1930,21 +1964,23 @@ export class FileSchedulerStoreAdapter {
1930
1964
  const handoffs = claimedMessages.filter(message => !obsoleteIds.has(message.id));
1931
1965
  // A quiet/new Session is not evidence that the claimed old Turn stopped.
1932
1966
  // Keep the exact pending intent until the original fence is provable.
1933
- if (handoffs.some((message) => interruptThenTerminalState(store, message) !== "ready"))
1967
+ if (recoveryRefs.length === 0
1968
+ && handoffs.some((message) => interruptThenTerminalState(store, message) !== "ready"))
1934
1969
  return null;
1935
1970
  const handoffIds = new Set(handoffs.map((message) => message.id));
1936
1971
  if (availableRefs.length === 0 && obsoleteIds.size > 0) {
1937
1972
  store.saveWorkMailbox(consumePendingBatch(mailbox, pending));
1938
1973
  return null;
1939
1974
  }
1940
- const selectedRefs = handoffs.length === 0 ? availableRefs
1975
+ const selectedRefs = recoveryRefs.length > 0 ? recoveryRefs : handoffs.length === 0 ? availableRefs
1941
1976
  : availableRefs.filter((ref) => ref.type === "message" && handoffIds.has(ref.id));
1942
- const remainingRefs = handoffs.length === 0 ? []
1943
- : 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)));
1944
1980
  const latest = latestTaskWake(store.listTaskWakes(taskId));
1945
1981
  const wakeId = store.nextTaskWakeId(taskId);
1946
1982
  const wake = createTaskWake({
1947
- id: wakeId, taskId, reasons: mailbox.pending.reasons,
1983
+ id: wakeId, taskId, reasons: recoveryRefs.length > 0 ? ["session-replaced"] : mailbox.pending.reasons,
1948
1984
  refs: pinMessageRevisions(store, taskId, selectedRefs),
1949
1985
  fromCursor: latest?.toCursor ?? task.createdAt,
1950
1986
  toCursor: new Date(Math.max(now.getTime(), Date.parse(mailbox.pending.lastQueuedAt))).toISOString(), now
@@ -2231,6 +2267,8 @@ export class FileSchedulerStoreAdapter {
2231
2267
  }
2232
2268
  sessions = settleStructuredProviderTurn(sessions, canonicalRunId, input.providerStatus, now, attemptId);
2233
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);
2234
2272
  }
2235
2273
  if (input.observation !== undefined)
2236
2274
  this.persistRuntimeObservation(input.observation, now);
@@ -2336,6 +2374,8 @@ export class FileSchedulerStoreAdapter {
2336
2374
  }
2337
2375
  if (input.observation !== undefined)
2338
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);
2339
2379
  return {
2340
2380
  session: sessions.sessions[input.agentId],
2341
2381
  duplicate: false,
@@ -2822,6 +2862,10 @@ export class FileSchedulerStoreAdapter {
2822
2862
  // durable native terminal recorded just above remains the proof of record.
2823
2863
  current = settleGlobalRoleRuntimeTurn(current, input.nativeTurnId, input.providerStatus, now);
2824
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
+ }
2825
2869
  if (input.roleName === SYSTEM_OPERATOR_ROLE
2826
2870
  && input.adapterId === "codex"
2827
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
+ }
@@ -2,6 +2,7 @@ import { admitProviderRetry, cancelProviderRetry, providerRetryAttemptId, provid
2
2
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
3
3
  import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
4
4
  import { isDeepStrictEqual } from "node:util";
5
+ import { isHandlingReminder } from "../message/inboxTurn.js";
5
6
  export function ownedProviderInput(input) {
6
7
  if (input.runId !== undefined && input.taskId !== undefined) {
7
8
  return { kind: "run", taskId: input.taskId, runId: input.runId };
@@ -26,6 +27,7 @@ export function retryIntentBlocker(store, input, retry) {
26
27
  return "role-configuration-changed";
27
28
  if (store.listGlobalRoleMessages(input.roleName).some(message => (message.kind === "user" || message.kind === "operator"
28
29
  || message.inputControl !== undefined || message.interruptThen !== undefined)
30
+ && !isHandlingReminder(message)
29
31
  && Date.parse(message.createdAt) > Date.parse(retry.intentAt)
30
32
  && message.delivery === undefined && message.notDelivered === undefined))
31
33
  return "new-user-input";
@@ -1,5 +1,6 @@
1
1
  import { createExecFileCommandRunner, createPinnedCommandRunner } from "./pinnedCommandRunner.js";
2
2
  import { redactLaunchText } from "../runtime/launchDiagnostics.js";
3
+ import { canonicalPublicationUrl } from "../task/publicationToolObservation.js";
3
4
  const GIT_COMMIT = /^[0-9a-f]{40}$/iu;
4
5
  const GITLAB_REPOSITORY = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u;
5
6
  const MAX_FAILURE_DETAIL_CHARS = 4_000;
@@ -64,8 +65,13 @@ export function parseGitLabMergeRequest(stdout, repository, externalId, expected
64
65
  }
65
66
  const externalUrl = optionalText(record.web_url);
66
67
  assertGitLabReference(record.references, repository, externalId);
67
- if (externalUrl !== undefined && expectedHost !== undefined) {
68
- assertGitLabWebUrl(externalUrl, expectedHost, repository, externalId);
68
+ if (externalUrl !== undefined) {
69
+ const url = new URL(canonicalPublicationUrl(externalUrl));
70
+ // references.full and iid establish identity; web_url is a browser link,
71
+ // not a repository/MR path schema. The command also checks the candidate URL.
72
+ if (expectedHost !== undefined && url.host.toLowerCase() !== expectedHost.toLowerCase()) {
73
+ throw new Error(`GitLab MR web URL does not match service ${expectedHost}.`);
74
+ }
69
75
  }
70
76
  const mergedAt = optionalText(record.merged_at);
71
77
  return {
@@ -103,21 +109,6 @@ function assertGitLabReference(value, repository, externalId) {
103
109
  + `${repository}!${externalId}.`);
104
110
  }
105
111
  }
106
- function assertGitLabWebUrl(value, expectedHost, repository, externalId) {
107
- let url;
108
- try {
109
- url = new URL(value);
110
- }
111
- catch {
112
- throw new Error("GitLab MR web URL is invalid.");
113
- }
114
- const expectedSuffix = `/${repository}/-/merge_requests/${externalId}`;
115
- if ((url.protocol !== "https:" && url.protocol !== "http:")
116
- || url.host.toLowerCase() !== expectedHost.toLowerCase()
117
- || !url.pathname.endsWith(expectedSuffix)) {
118
- throw new Error(`GitLab MR web URL does not match ${expectedHost}/${repository}!${externalId}.`);
119
- }
120
- }
121
112
  function firstCommit(...values) {
122
113
  for (const value of values) {
123
114
  if (value === null || value === undefined || value === "")
@@ -140,17 +131,8 @@ function optionalText(value) {
140
131
  function gitLabHostname(externalUrl) {
141
132
  if (externalUrl === undefined)
142
133
  return undefined;
143
- let url;
144
- try {
145
- url = new URL(externalUrl);
146
- }
147
- catch {
148
- throw new Error(`GitLab external URL is invalid: ${externalUrl}.`);
149
- }
150
- if (url.protocol !== "https:" && url.protocol !== "http:") {
151
- throw new Error(`GitLab external URL protocol is invalid: ${url.protocol}.`);
152
- }
153
- return url.hostname === "gitlab.com" ? undefined : url.host;
134
+ const url = new URL(canonicalPublicationUrl(externalUrl));
135
+ return url.host === "gitlab.com" ? undefined : url.host;
154
136
  }
155
137
  function boundedFailureDetail(value) {
156
138
  return value.length <= MAX_FAILURE_DETAIL_CHARS
@@ -13,6 +13,12 @@ export function taskAuthorizationSource(store, taskId, environment, messageId, p
13
13
  || task.executionGate?.state === "stopped") {
14
14
  throw usageError("Task authorization is unavailable in this lifecycle or execution gate.");
15
15
  }
16
+ return { ...readTaskAuthorizationMessage(store, taskId, messageId, purpose),
17
+ nativeSessionId: caller.nativeSessionId };
18
+ }
19
+ /** Shared source binding; interpreting the quoted authority remains the caller's
20
+ * responsibility. A matching quote proves provenance, not semantic permission. */
21
+ export function readTaskAuthorizationMessage(store, taskId, messageId, purpose) {
16
22
  const message = store.listMessages(taskId).find(entry => entry.id === messageId);
17
23
  if (!message || !((message.kind === "user" && message.author.type === "user")
18
24
  || (message.kind === "operator" && message.author.type === "operator"))) {
@@ -29,8 +35,7 @@ export function taskAuthorizationSource(store, taskId, environment, messageId, p
29
35
  taskId: message.taskId, id: message.id, kind: message.kind,
30
36
  author: message.author, body: message.body, createdAt: message.createdAt
31
37
  })).digest("hex"),
32
- purpose: purpose.trim(),
33
- nativeSessionId: caller.nativeSessionId
38
+ purpose: purpose.trim()
34
39
  };
35
40
  }
36
41
  const RELEASE_ACTIONS = new Set([
@@ -69,6 +69,13 @@ export function taskNotificationPresentation(wake, store) {
69
69
  ...(messages.length > 4 ? [`${messages.length - 4} more messages in the fixed wake.`] : []),
70
70
  `Refresh: yui task context ${wake.taskId} --json`,
71
71
  `Read inputs/results: yui task wake show ${wake.taskId} ${wake.id} --json`,
72
+ `Handling: claim exact inbox entry ${wake.id}; after disposition, ack it with a concrete reason before ending this turn.`,
73
+ "Use yui inbox list --view current --json for current inputs. Historical missing receipts are not new work; never replay effects to repair an ACK.",
74
+ ...(wake.reasons.includes("session-replaced") ? [
75
+ "Session replacement is initialization, not replay or completion of earlier input.",
76
+ "Read yui inbox list --view history --json; inspect retained originals, prior effects and current authority before explicit settlement.",
77
+ "Preserve original claims and delivery targets. Unknown external effects require evidence, not another execution."
78
+ ] : []),
72
79
  "Check current Skills each turn: yui session skills --json. Apply current yui-leader; read relevant originals in full and advance authorized work."
73
80
  ].join("\n");
74
81
  }
@@ -80,6 +87,8 @@ export function globalMessagePresentation(roleName, messageId, message) {
80
87
  ...(summary === undefined ? [] : [`Author summary (message-body): ${JSON.stringify(summary)}`]),
81
88
  "Refresh Session Context using the current Manifest's loadCommand.",
82
89
  `Read original: yui role message show ${roleName} ${messageId} --json`,
90
+ `Handling: claim exact inbox entry ${messageId}; after disposition, ack it with a concrete reason before ending this turn.`,
91
+ "Routing to a Task or asking a necessary user question can settle this input without completing the Task. Missing ACK is not permission to repeat effects.",
83
92
  `The Global loadCommand checks current Skill versions each turn. Apply current ${roleName === "operator" ? "yui-operator" : "yui-runtime"} within existing authority.`
84
93
  ].join("\n");
85
94
  }
@@ -0,0 +1,44 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createGlobalRoleMessage } from "./message.js";
3
+ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
+ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
5
+ import { INBOX_CHECK_PREFIX } from "./inboxTurn.js";
6
+ const requestId = (agentId, nativeSessionId) => `${INBOX_CHECK_PREFIX}recovery:${createHash("sha256").update(JSON.stringify([agentId, nativeSessionId])).digest("hex")}`;
7
+ /** Derive a single initialization notice from existing retained claims. New
8
+ * Sessions are created only by the existing authorized lifecycle path. A
9
+ * restart can finish notice creation, but cannot select or replace a Session. */
10
+ export function globalInboxRecovery(store, roleName, now) {
11
+ return store.transaction(tx => {
12
+ const sessions = tx.getGlobalRoleSessionSet(roleName);
13
+ const session = sessions?.sessions[sessions.activeAgentId];
14
+ const binding = sessions?.providerBinding;
15
+ if (!session || session.status !== "active" || !session.nativeSessionId || !binding
16
+ || binding.authority.owner !== "controller"
17
+ || currentProviderConversation(binding).conversationId !== session.nativeSessionId
18
+ || binding.run && ["submitting", "accepted", "delivery-unknown"].includes(binding.run.status)
19
+ || hasRuntimeCleanupObligation(tx.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName }))))
20
+ return;
21
+ if (tx.queryGlobalRoleMessages(roleName, { handling: true, handlingExceptSession: {
22
+ agentId: session.agentId, nativeSessionId: session.nativeSessionId
23
+ }, limit: 0 }).count === 0)
24
+ return;
25
+ const id = requestId(session.agentId, session.nativeSessionId);
26
+ const existing = tx.queryGlobalRoleMessages(roleName, { requestId: id, limit: 1 }).messages[0];
27
+ if (existing)
28
+ return existing;
29
+ const notice = {
30
+ ...createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), roleName, [
31
+ "Yui-Summary: This successor has retained inbox handling obligations; inspect original evidence before settlement.",
32
+ "Read current Session Context and yui inbox list --view history --json. Read the exact original, original claim and prior effects.",
33
+ "This is initialization only: do not replay old input, retarget its delivery, or infer business completion.",
34
+ "After handling under current authority, explicitly acknowledge the original entry without replacing its claim.",
35
+ "Uncertain external effects require verification or a real user decision. New queued input remains separate."
36
+ ].join("\n"), "system", { type: "system" }, now, {
37
+ inputControl: { action: "queue", requestId: id, expectedSessionId: session.nativeSessionId }
38
+ }),
39
+ deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId }
40
+ };
41
+ tx.saveGlobalRoleMessage(notice);
42
+ return notice;
43
+ });
44
+ }
@@ -11,7 +11,7 @@ export function recordGlobalSteerResult(store, roleName, messageId, receipt) {
11
11
  const now = new Date();
12
12
  const outcome = receipt.state === "steered" ? "accepted"
13
13
  : receipt.state === "steer-unknown" ? "delivery-unknown" : "rejected";
14
- const updated = { ...message, control: {
14
+ const updated = { ...message, control: { ...message.control,
15
15
  requestId: source.requestId, receiptId: `steer:${roleName}/${messageId}`,
16
16
  outcome, observedAt: now.toISOString()
17
17
  } };