@zq-silk/yui 2.18.0 → 2.20.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 (36) hide show
  1. package/dist/cli/commandCatalog.js +2 -0
  2. package/dist/commands/controllerCommands.js +1 -0
  3. package/dist/commands/roleConfiguration.js +17 -0
  4. package/dist/controller/controller.js +10 -0
  5. package/dist/controller/fileSchedulerStoreAdapter.js +26 -2
  6. package/dist/controller/messageHealth.js +354 -0
  7. package/dist/controller/runtime.js +9 -1
  8. package/dist/controller/runtimeLaunchCoordinator.js +12 -1
  9. package/dist/controller/taskHostTerminalRecovery.js +11 -0
  10. package/dist/executor/agentExecutor.js +7 -0
  11. package/dist/executor/fileRoleLaunchPlanner.js +22 -6
  12. package/dist/output/rolePresentation.js +1 -0
  13. package/dist/role/instanceCode.js +7 -0
  14. package/dist/role/role.js +10 -0
  15. package/dist/runtime/agentEndpoint.js +1 -0
  16. package/dist/runtime/agentHost.js +9 -1
  17. package/dist/runtime/launchBroker.js +3 -0
  18. package/dist/runtime/runtimeBinding.js +3 -0
  19. package/dist/runtime/sessionTitle.js +29 -9
  20. package/dist/runtime/structuredProviderHost.js +11 -1
  21. package/dist/runtime/tmuxAdapters.js +4 -0
  22. package/dist/storage/contextRecords.js +16 -0
  23. package/dist/storage/sqliteSchema.js +10 -0
  24. package/dist/storage/sqliteStore.js +16 -0
  25. package/dist/storage/storageVersions.js +1 -1
  26. package/docs/managed-turn-and-session-runtime.md +96 -0
  27. package/docs/managed-turn-and-session-runtime.zh-CN.md +65 -0
  28. package/docs/release-workflow.md +49 -0
  29. package/docs/release-workflow.zh-CN.md +37 -0
  30. package/docs/roles-and-configuration.md +49 -0
  31. package/docs/roles-and-configuration.zh-CN.md +38 -0
  32. package/docs/storage-baseline.md +4 -1
  33. package/docs/storage-baseline.zh-CN.md +1 -1
  34. package/docs/testing/verification-levels.md +2 -2
  35. package/docs/testing/verification-levels.zh-CN.md +1 -1
  36. package/package.json +1 -1
@@ -136,6 +136,7 @@ const agentChildren = [
136
136
  { name: "remove", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Remove a configured Agent.", usage: "yui config agent remove <id>" }
137
137
  ];
138
138
  const roleProfileOptions = [
139
+ "--instance-code",
139
140
  "--description", "--responsibility", "--constraint",
140
141
  "--expected-output", "--system-prompt", "--skill"
141
142
  ];
@@ -144,6 +145,7 @@ const roleAgentOptions = [
144
145
  "--allowed-tool", "--disallowed-tool", "--search"
145
146
  ];
146
147
  const roleProfileClearOptions = [
148
+ "--clear-instance-code",
147
149
  "--clear-description", "--clear-responsibilities", "--clear-constraints",
148
150
  "--clear-expected-output", "--clear-system-prompt", "--clear-skills"
149
151
  ];
@@ -187,6 +187,7 @@ export function parseControllerRuntimeSnapshot(result, droppedEvents) {
187
187
  reads: (runtime.reads ?? null),
188
188
  persistence: (runtime.persistence ?? null),
189
189
  diagnostics: (runtime.diagnostics ?? null),
190
+ messageHealth: (runtime.messageHealth ?? null),
190
191
  scheduler: (runtime.scheduler ?? null),
191
192
  preRoutingWait: "unmeasured; event-loop lag is sampled, not per-request queue time",
192
193
  clock: "process-monotonic milliseconds; target/request labels are sha256 prefixes"
@@ -1,4 +1,5 @@
1
1
  import { usageError } from "../errors/cliError.js";
2
+ import { validateInstanceCode } from "../role/instanceCode.js";
2
3
  import { parseEnabled } from "../role/availability.js";
3
4
  import { defaultRoleAgentConfig, resolveAgentAdapter } from "../executor/agentAdapter.js";
4
5
  const PROFILE_OPTIONS = [
@@ -66,6 +67,8 @@ export function roleOptionSpecs(input) {
66
67
  ...(input.includeWorkspace === true ? [["--workspace", "value"]] : []),
67
68
  ...(input.update ? [["--yes", "flag"]] : []),
68
69
  ...(input.update ? [["--enabled", "value"]] : []),
70
+ ["--instance-code", "value"],
71
+ ...(input.update ? [["--clear-instance-code", "flag"]] : []),
69
72
  ...PROFILE_OPTIONS,
70
73
  ...agentValueOptions,
71
74
  ...agentRepeatableOptions,
@@ -105,6 +108,7 @@ export function parseRoleOptions(args, specs, usage) {
105
108
  }
106
109
  export function roleProfileFrom(parsed) {
107
110
  return {
111
+ ...roleDisplayPatch(parsed),
108
112
  ...(parsed.has("--description") ? { description: requiredText(parsed.one("--description")) } : {}),
109
113
  responsibilities: parsed.many("--responsibility").map(requiredText),
110
114
  constraints: parsed.many("--constraint").map(requiredText),
@@ -125,6 +129,7 @@ export function roleProfilePatch(parsed) {
125
129
  ["--skill", "--clear-skills"]
126
130
  ]);
127
131
  return {
132
+ ...roleDisplayPatch(parsed),
128
133
  ...(parsed.has("--enabled") ? { enabled: parseEnabled(parsed.one("--enabled")) } : {}),
129
134
  ...(parsed.has("--description") ? { description: requiredText(parsed.one("--description")) } : {}),
130
135
  ...(parsed.has("--clear-description") ? { description: undefined } : {}),
@@ -144,6 +149,18 @@ export function roleProfilePatch(parsed) {
144
149
  ...(parsed.has("--clear-skills") ? { skills: [] } : {})
145
150
  };
146
151
  }
152
+ function roleDisplayPatch(parsed) {
153
+ assertPairs(parsed, [["--instance-code", "--clear-instance-code"]]);
154
+ try {
155
+ return {
156
+ ...(parsed.has("--instance-code") ? { instanceCode: validateInstanceCode(parsed.one("--instance-code")) } : {}),
157
+ ...(parsed.has("--clear-instance-code") ? { instanceCode: undefined } : {})
158
+ };
159
+ }
160
+ catch (error) {
161
+ throw usageError(error instanceof Error ? error.message : String(error));
162
+ }
163
+ }
147
164
  export function hasAgentConfigOptions(parsed) {
148
165
  return [...parsed.seen].some((option) => AGENT_CONFIG_OPTIONS.has(option));
149
166
  }
@@ -693,6 +693,8 @@ export class FileTaskController {
693
693
  #jobSupervisor;
694
694
  #schedules;
695
695
  #globalInputDelivery;
696
+ #messageHealth;
697
+ #lastMessageHealth = null;
696
698
  #providerRetry;
697
699
  #continuationReconciler;
698
700
  #hostTerminalRecovery;
@@ -753,6 +755,7 @@ export class FileTaskController {
753
755
  this.#continuationReconciler = options.continuationReconciler;
754
756
  this.#hostTerminalRecovery = options.hostTerminalRecovery;
755
757
  this.#globalInputDelivery = options.globalInputDelivery;
758
+ this.#messageHealth = options.messageHealth;
756
759
  this.#providerRetry = options.providerRetry;
757
760
  this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
758
761
  windowMs: options.signalWindowMs ?? DEFAULT_SIGNAL_WINDOW_MS,
@@ -789,6 +792,7 @@ export class FileTaskController {
789
792
  runtimeMetrics() {
790
793
  const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
791
794
  return {
795
+ messageHealth: this.#lastMessageHealth,
792
796
  diagnostics: this.#diagnosticMetrics?.() ?? null,
793
797
  phases: this.#phases.snapshot(),
794
798
  persistence: this.#persistenceMetrics?.() ?? null,
@@ -1015,6 +1019,12 @@ export class FileTaskController {
1015
1019
  if (scope.kind === "full" && this.#hostTerminalRecovery !== undefined) {
1016
1020
  await this.#observeMaintenance("Host terminal recovery", this.#hostTerminalRecovery);
1017
1021
  }
1022
+ if (scope.kind === "full" && this.#messageHealth !== undefined) {
1023
+ // Consume already collected Host evidence, never another live probe.
1024
+ const health = await this.#observeMaintenance("message-health", () => this.#messageHealth(this.#now()));
1025
+ if (health !== undefined)
1026
+ this.#lastMessageHealth = health;
1027
+ }
1018
1028
  await this.#phases.observe("provider-retry", "home", () => this.#providerRetry?.reconcile());
1019
1029
  await this.#phases.observe("global-input", "home", () => this.#globalInputDelivery?.({ reconcile: scope.kind === "full", now: this.#now() }));
1020
1030
  for (const taskId of runtimeTaskFailureIds(firstRuntimeDrain)) {
@@ -35,6 +35,7 @@ import { recordTaskAgentError } from "./taskAgentError.js";
35
35
  import { recordGlobalRuntimeAttention, recordGlobalRuntimeFailure, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
36
36
  import { checkInboxTurn } from "./inboxTurnCheck.js";
37
37
  import { queueOperatorNotification } from "./operatorNotification.js";
38
+ import { reconcileMessageHealth } from "./messageHealth.js";
38
39
  import { reconcileProjectSync } from "../job/projectSync.js";
39
40
  import { serializeError } from "../core/boundedRpc.js";
40
41
  import { claimOperatorAttention } from "../scheduler/operatorEvent.js";
@@ -95,6 +96,9 @@ export class FileSchedulerStoreAdapter {
95
96
  this.drivers = drivers;
96
97
  this.snapshotExecutionLaneWorkspace = snapshotExecutionLaneWorkspace;
97
98
  }
99
+ reconcileMessageHealth(now, hosts) {
100
+ return reconcileMessageHealth(this.store, now, hosts);
101
+ }
98
102
  /** Registry maintenance only; this never executes a Job or starts a process. */
99
103
  reconcileProjectSync(now) {
100
104
  const errors = [];
@@ -2110,8 +2114,14 @@ export class FileSchedulerStoreAdapter {
2110
2114
  && existing.nativeSessionId !== input.session.nativeSessionId
2111
2115
  && existing.status === "active";
2112
2116
  if (!defersConversationReplacement && (existing?.nativeSessionId !== input.session.nativeSessionId || existing.status !== "active")) {
2117
+ // Preparation reactivates the fixed native identity, not its name.
2118
+ // Preserve the stored title even for an ended Session; only the
2119
+ // naming receipt may advance it on resume.
2120
+ const { title, ...preparedSession } = input.session;
2113
2121
  saveTaskSession(store, role, {
2114
- ...input.session,
2122
+ ...preparedSession,
2123
+ ...(existing?.nativeSessionId !== input.session.nativeSessionId && title !== undefined
2124
+ ? { title } : {}),
2115
2125
  nativeSessionId: input.session.nativeSessionId
2116
2126
  }, "active", input.now);
2117
2127
  }
@@ -2176,7 +2186,7 @@ export class FileSchedulerStoreAdapter {
2176
2186
  recordLaunchedRuntimeNativeSession(input, assertCurrent, now = new Date()) {
2177
2187
  return this.store.transaction((store) => {
2178
2188
  assertCurrent();
2179
- const session = input.owner.scope === "task"
2189
+ let session = input.owner.scope === "task"
2180
2190
  ? recordTaskRuntimeNativeSession(store, {
2181
2191
  taskId: input.owner.taskId,
2182
2192
  roleName: input.owner.roleName,
@@ -2192,6 +2202,20 @@ export class FileSchedulerStoreAdapter {
2192
2202
  nativeSessionId: input.nativeSessionId,
2193
2203
  effective: input.effective
2194
2204
  }, now);
2205
+ if (input.generatedTitle !== undefined && (session.title === undefined
2206
+ || session.title === session.generatedTitle || session.title === input.generatedTitle)) {
2207
+ const set = input.owner.scope === "task"
2208
+ ? store.getTaskRoleSessionSet(input.owner.taskId, input.owner.roleName)
2209
+ : store.getGlobalRoleSessionSet(input.owner.roleName);
2210
+ const titled = recordRoleAgentSession(set, {
2211
+ ...session, title: input.generatedTitle, generatedTitle: input.generatedTitle
2212
+ }, now);
2213
+ if (titled.owner.scope === "task")
2214
+ store.saveTaskRoleSessionSet(titled);
2215
+ else
2216
+ store.saveGlobalRoleSessionSet(titled);
2217
+ session = titled.sessions[input.agentId];
2218
+ }
2195
2219
  if (input.owner.scope === "global") {
2196
2220
  let sessions = store.getGlobalRoleSessionSet(input.owner.roleName);
2197
2221
  let binding = sessions.providerBinding;
@@ -0,0 +1,354 @@
1
+ import { createTaskEvent } from "../event/taskEvent.js";
2
+ import { createGlobalRoleMessage } from "../message/message.js";
3
+ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
+ import { providerRetryPending } from "../runtime/providerRetry.js";
5
+ import { globalInputBacklog } from "../message/globalInputBacklog.js";
6
+ // These are observation thresholds, not leases or authority to release a writer.
7
+ export const MESSAGE_HEALTH_WARNING_MS = 5 * 60_000;
8
+ export const MESSAGE_HEALTH_CRITICAL_MS = 30 * 60_000;
9
+ const HEADER = "Yui message health v1\n";
10
+ const NOTICE = "operator-notice:message-health:";
11
+ export function classifyMessageHealth(facts, now) {
12
+ const ageMs = facts.oldestAt === null ? 0 : Math.max(0, +now - Date.parse(facts.oldestAt));
13
+ const occupancyMs = facts.processingAt === null ? 0 : Math.max(0, +now - Date.parse(facts.processingAt));
14
+ let state;
15
+ let reason;
16
+ if (facts.stopped) {
17
+ state = "paused";
18
+ reason = "explicit-stop-pause-or-lifecycle";
19
+ }
20
+ else if (facts.count === 0 && facts.handling) {
21
+ state = "busy";
22
+ reason = "claimed-input-handling-is-not-delivery-proof";
23
+ }
24
+ else if (facts.count === 0 && facts.processingAt === null) {
25
+ state = "clear";
26
+ reason = "no-awaiting-delivery";
27
+ }
28
+ else if (facts.turnStatus === "accepted" && !facts.blocked) {
29
+ state = "busy";
30
+ reason = "accepted-turn-duration-is-not-a-fault";
31
+ }
32
+ else if (facts.retryDeadline !== undefined && +now <= Date.parse(facts.retryDeadline)) {
33
+ state = "recovering";
34
+ reason = "bounded-provider-retry-owned";
35
+ }
36
+ else {
37
+ reason = facts.retryDeadline !== undefined ? "retry-budget-expired"
38
+ : facts.blocked ? "retained-delivery-or-processing-barrier"
39
+ : facts.turnStatus === "delivery-unknown" ? "delivery-uncertain"
40
+ : facts.turnStatus === "submitting" ? "submission-unsettled" : "no-delivery-progress";
41
+ state = Math.max(ageMs, occupancyMs) >= MESSAGE_HEALTH_CRITICAL_MS ? "critical"
42
+ : Math.max(ageMs, occupancyMs) >= MESSAGE_HEALTH_WARNING_MS ? "warning" : "waiting";
43
+ }
44
+ return { state, reason, ageMs, occupancyMs };
45
+ }
46
+ // Display-only shell arguments; guidance never executes these commands.
47
+ function arg(value) {
48
+ return /^[a-zA-Z0-9_.:/-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
49
+ }
50
+ function recoveryGuidance(o) {
51
+ const { taskId, roleName } = o.target;
52
+ const owner = taskId === undefined ? "Operator (or the user if Operator itself is unavailable)"
53
+ : roleName === "leader" ? "Operator; the affected Leader must not synchronously stop itself"
54
+ : "the authorized Task Leader, or Operator";
55
+ const retryRead = taskId === undefined ? `yui session retry ${arg(roleName)} show`
56
+ : `yui task role session retry ${arg(taskId)} ${arg(roleName)} show`;
57
+ const lines = [
58
+ `Responsible: ${owner}. Guidance is not authorization and executes nothing.`,
59
+ `Snapshot ${o.observedAt}; Current state: ${o.state}; reason: ${o.reason}. Refresh yui controller status --verbose --json before acting; an old notice is not a current instruction.`,
60
+ `Exact target: Session=${o.session ?? "unreported"}, attempt=${o.attemptId ?? "unreported"}, batch=${o.batchId ?? "none"}, wake=${o.wakeId ?? "none"}.`,
61
+ `Read first: ${[...o.reads, retryRead, ...(o.deliveryEvidence ? [o.deliveryEvidence.read] : [])].join("; ")}.`,
62
+ "Check current CLI help and reported Host capabilities as the responsible caller. Missing capability, inaccessible evidence or an identity change blocks the proposed action; do not guess another command or target."
63
+ ];
64
+ if (o.state === "recovering") {
65
+ lines.push("Existing bounded retry owns recovery. Observe its exact attempt/deadline; do not duplicate or interrupt it. Escalate its retained failure only if exhausted or needs-attention.");
66
+ }
67
+ else if (o.state === "busy" || o.state === "waiting") {
68
+ lines.push("No intervention is indicated: accepted work or claimed handling can be slow. Inspect actual progress and delivery evidence; silence or a missing ACK is not permission to interrupt.");
69
+ }
70
+ else if (o.state === "paused") {
71
+ lines.push("Respect the deliberate stop/pause, human ownership, matching InputRequest or pending lifecycle cleanup. Inspect that existing obligation; this notice does not authorize resumption or another stop.");
72
+ }
73
+ else if (o.state === "clear") {
74
+ lines.push("Only delivery backlog clearance was observed. No management action is indicated; inspect original inputs and handling before concluding business recovery.");
75
+ }
76
+ else if (taskId === undefined) {
77
+ lines.push("No per-GlobalRole Host-stop command is established by this candidate. Read the original Global Message/control and retry evidence; use current caller capability discovery for a supported targeted lifecycle action. Do not translate Task commands to a GlobalRole or use a stop-all fallback.");
78
+ }
79
+ else {
80
+ const target = `${arg(taskId)} ${arg(roleName)}`;
81
+ lines.push("Choose only the path matching refreshed evidence, not a stop/replace/retry sequence. Prefer correction plus guarded retry for a proven rejection when sufficient; stop is for an independently established residual Host barrier, and replacement is not the default.");
82
+ lines.push(`Observed barrier: delivery=${o.deliveryEvidence?.outcome ?? "unreported"}; detail=${o.deliveryEvidence?.detail ?? "unreported"}; Host=${o.hostObservation.state}/${o.hostObservation.reconciliation ?? "unreported"}; retry=${o.retry?.status ?? "unreported"}. These observations do not establish quiescence or prove Goal is the cause; do not clear Goal merely because it says stalled.`);
83
+ if (o.session !== null && o.blocked) {
84
+ lines.push(`For confirmed residual Host ownership, the responsible supervisor may consider the existing targeted management path without waiting for a full repair package or every historical projection to align. Template: yui task role session stop ${target} --reason '<verified target, authority and stop rationale>'. First refresh the exact Session/Host identity, native/background work and event persistence; normal busy work, owned retry, intentional pause, unpersisted Host events or unknown cleanup consequences block a routine stop. The CLI captures and fences the current target; this snapshot cannot pin a later invocation.`);
85
+ lines.push("A stop request being saved is not physical stop. Require the exact Host/resource cleanup result; if cleanup fails or volatile facts remain, retain that process and evidence and escalate the precise boundary, without force-kill or claiming it stopped. After proven stop, let existing authorized delivery/lifecycle reuse the retained conversation when supported; verify the actual native Session identity, never infer reuse from Host ready.");
86
+ lines.push(`Only if reuse is unsuitable and deliberate replacement is justified, preserve durable progress and use the separately authorized template: yui task role session new ${target} --reason '<why replacement is necessary>'. This requests fresh Session selection after fenced cleanup, not same-conversation reuse. A self-replacing Leader must end its turn. Retained inputs/claims still require inspection, not replay.`);
87
+ }
88
+ else {
89
+ lines.push("A current controlled Session/residual Host barrier is not established. Inspect the missing runtime or Assignment evidence first; no targeted stop is recommended from this snapshot.");
90
+ }
91
+ if (o.wakeId !== null && roleName === "leader") {
92
+ const wakeTarget = `${arg(taskId)} ${arg(o.wakeId)}`;
93
+ if (o.deliveryEvidence?.outcome === "rejected") {
94
+ lines.push(`After correcting the rejection cause, and only if this exact wake is still unconsumed, its original claim matches and native acceptance/activity are proven absent: yui task wake retry ${wakeTarget} --reason '<verified correction and quiescence>'. Queued is not accepted. Do not retry accepted/unknown input. This candidate does not establish resolve support for rejected wakes; do not assume another recovery package is installed.`);
95
+ }
96
+ else if (o.deliveryEvidence?.outcome === "unknown") {
97
+ lines.push(`Do not replay unknown input. After exact shared-native/background/writer quiescence and persisted evidence, with the original unknown claim still current: yui task wake resolve ${wakeTarget} --reason '<exact quiescence evidence>'. This releases only scheduling without replay, ACK or acceptance. The rightful handler must inspect original effects and settle still-valid obligations separately.`);
98
+ }
99
+ else {
100
+ lines.push("Deferred, accepted or unreported delivery does not meet this candidate's rejected-retry/unknown-resolve preconditions. Keep the original wake and inspect the runtime barrier instead of mechanically invoking either action.");
101
+ }
102
+ }
103
+ }
104
+ lines.push("Verify separately: management request saved; exact physical stop if requested; retained conversation reused or explicit replacement recorded; subsequent original wake/message actually accepted by Provider; real handling/progress and remaining obligations. Host ready, pending=0, command success or alert ACK alone proves none of the later stages. If evidence is missing, recovery remains unverified.");
105
+ lines.push("If an action is refused or fails, retain its exact target, receipt/error, original input and workspace; report the smallest missing capability, authority or cleanup evidence to the responsible supervisor. No private retry loop, forced ACK, queue clearing, raw DB edits, unknown-effect replay, model/account/permission changes or whole-Controller/all-Session restart.");
106
+ return lines.join("\n");
107
+ }
108
+ function eventType(roleName) { return `message.health.${roleName}`; }
109
+ function prefix(target) { return `${HEADER}${target.roleName}\n`; }
110
+ function noticeId(saved) { return NOTICE + encodeURIComponent(saved.id); }
111
+ function latest(store, target) {
112
+ if (target.taskId !== undefined) {
113
+ const event = store.queryContextRecords(target.taskId, { family: "task-event",
114
+ eventTypes: [eventType(target.roleName)], descending: true, limit: 1 }).records[0]?.value;
115
+ return event ? { id: `${target.taskId}/${event.id}`,
116
+ read: `yui task event show ${arg(target.taskId)} ${arg(event.id)}`,
117
+ evidence: JSON.parse(event.payload.evidence) } : null;
118
+ }
119
+ const message = store.queryGlobalRoleMessages(target.roleName, {
120
+ systemBodyPrefix: prefix(target), descending: true, limit: 1
121
+ }).messages[0];
122
+ return message ? { id: `${target.roleName}/${message.id}`,
123
+ read: `yui role message show ${arg(target.roleName)} ${arg(message.id)}`,
124
+ evidence: JSON.parse(message.body.slice(prefix(target).length)) } : null;
125
+ }
126
+ function record(store, target, evidence, now) {
127
+ if (target.taskId !== undefined) {
128
+ const event = createTaskEvent(store.nextEventId(target.taskId), target.taskId, eventType(target.roleName), { evidence: JSON.stringify(evidence) }, now);
129
+ store.saveEvent(target.taskId, event);
130
+ return { id: `${target.taskId}/${event.id}`, read: `yui task event show ${arg(target.taskId)} ${arg(event.id)}`, evidence };
131
+ }
132
+ const message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), target.roleName, prefix(target) + JSON.stringify(evidence), "system", { type: "system" }, now);
133
+ store.saveGlobalRoleMessage(message);
134
+ return { id: `${target.roleName}/${message.id}`, read: `yui role message show ${arg(target.roleName)} ${arg(message.id)}`, evidence };
135
+ }
136
+ function alertStatus(store, saved) {
137
+ if (!saved)
138
+ return { stage: "not-generated", reason: null, messageId: null };
139
+ const message = store.queryGlobalRoleMessages("operator", {
140
+ requestId: noticeId(saved), limit: 1
141
+ }).messages[0];
142
+ if (message?.inbox?.ack)
143
+ return { stage: "handled", reason: null, messageId: message.id };
144
+ if (message?.delivery)
145
+ return { stage: message.delivery.via === "provider" ? "delivered" : "transport-accepted",
146
+ reason: message.delivery.via === "provider" ? null : "provider-acceptance-unconfirmed", messageId: message.id };
147
+ if (message)
148
+ return { stage: "saved", messageId: message.id,
149
+ reason: message.notDelivered?.reason ?? message.control?.outcome
150
+ ?? (store.getGlobalRole("operator")?.enabled === false ? "operator-disabled"
151
+ : globalInputBacklog([message], store.getGlobalRoleSessionSet("operator")).reason) };
152
+ return { stage: "generated", messageId: null,
153
+ reason: store.getGlobalRole("operator") === null ? "operator-unconfigured"
154
+ : operatorAvailable(store) ? "operator-notice-pending" : "operator-unavailable" };
155
+ }
156
+ function operatorAvailable(store) {
157
+ const sessions = store.getGlobalRoleSessionSet("operator");
158
+ return sessions?.sessions[sessions.activeAgentId]?.status === "active" && sessions.providerBinding != null;
159
+ }
160
+ /** Same Global Message queue and receipt contract as ordinary Operator notices.
161
+ * No Task mailbox is involved. A busy/unavailable Operator never loses evidence.
162
+ * At most one unsent engineering notice; original failed notices are not replayed. */
163
+ function notify(store, saved, observation, now) {
164
+ if (store.getGlobalRole("operator") === null
165
+ || !operatorAvailable(store)
166
+ || store.queryGlobalRoleMessages("operator", { requestId: noticeId(saved), limit: 1 }).count
167
+ || store.queryGlobalRoleMessages("operator", { pending: true, operatorNotice: true, limit: 1 }).count)
168
+ return;
169
+ const { phase } = saved.evidence;
170
+ const sessions = store.getGlobalRoleSessionSet("operator");
171
+ const session = sessions.sessions[sessions.activeAgentId];
172
+ const message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), "operator", [
173
+ `Yui-Summary: Message delivery ${observation.state}: ${observation.target.taskId ?? "global"}/${observation.target.roleName} (retained ${phase} evidence).`,
174
+ `Inspect: ${saved.read}`,
175
+ `Original diagnostic phase=${phase}, first=${saved.evidence.firstAt}, latest evidence=${saved.evidence.at}; guidance below uses the current observation.`,
176
+ "Independent diagnostics: yui controller status --verbose --json",
177
+ `Waiting=${observation.count}, pending=${observation.pendingCount}, processing=${observation.processingCount}, ageMs=${observation.ageMs}, occupancyMs=${observation.occupancyMs}; answered inputs=${observation.answeredInputs.join(", ") || "none observed"}.`,
178
+ observation.recovery,
179
+ "Do not ACK business inputs, release unknown writers, replay input, replace Sessions or change models automatically."
180
+ ].join("\n"), "system", { type: "system" }, now, { inputControl: { action: "queue", requestId: noticeId(saved) } });
181
+ store.saveGlobalRoleMessage({ ...message,
182
+ deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId } });
183
+ }
184
+ function oldest(times) {
185
+ return times.filter((time) => time != null).sort()[0] ?? null;
186
+ }
187
+ function observe(store, target, now, hosts) {
188
+ const { taskId, roleName } = target;
189
+ const owner = taskId === undefined ? { scope: "global", roleName }
190
+ : { scope: "task", taskId, roleName };
191
+ const task = taskId === undefined ? null : store.getTask(taskId);
192
+ const role = taskId === undefined ? store.getGlobalRole(roleName) : store.getRole(taskId, roleName);
193
+ const sessions = taskId === undefined ? store.getGlobalRoleSessionSet(roleName)
194
+ : store.getTaskRoleSessionSet(taskId, roleName);
195
+ const session = sessions?.sessions[sessions.activeAgentId];
196
+ const binding = sessions?.providerBinding;
197
+ const currentBinding = binding != null && session !== undefined
198
+ && binding.conversations.some(conversation => conversation.status === "current"
199
+ && conversation.epoch === binding.currentConversationEpoch && conversation.conversationId === session.nativeSessionId);
200
+ const turn = currentBinding && binding?.run?.authorityEpoch === binding.authority.epoch ? binding.run : null;
201
+ const mailbox = taskId === undefined
202
+ ? roleName === "operator" ? store.getWorkMailbox({ kind: "operator" }) : null
203
+ : store.getWorkMailbox({ kind: "role", taskId, roleName });
204
+ const handlingOnly = mailbox?.processing?.owner.startsWith("agent-inbox:") ?? false;
205
+ const batches = [mailbox?.pending, handlingOnly ? null : mailbox?.processing?.batch].filter(batch => batch != null);
206
+ const refs = batches.flatMap(batch => [...batch.refs]);
207
+ const wakeId = mailbox?.processing?.owner.startsWith("leader-notification:")
208
+ ? mailbox.processing.owner.slice("leader-notification:".length) : null;
209
+ const wake = wakeId && taskId ? store.getTaskWake(taskId, wakeId) : null;
210
+ const delivery = taskId && mailbox?.processing ? store.queryContextRecords(taskId, {
211
+ family: "task-event", eventTypes: ["notification.delivery"],
212
+ eventAttemptId: mailbox.processing.batchId, descending: true, limit: 1
213
+ }).records[0]?.value : undefined;
214
+ // Delivery failures stay visible even when ordinary pending excludes notDelivered.
215
+ // Handling-only historical receipts never become new queue work.
216
+ const globalPage = taskId === undefined ? store.queryGlobalRoleMessages(roleName, {
217
+ awaitingDelivery: true, unclaimed: true, excludeHealthNotices: true, limit: 8
218
+ }) : null;
219
+ const handling = handlingOnly || taskId === undefined && store.queryGlobalRoleMessages(roleName, {
220
+ awaitingDelivery: true, handling: true, excludeHealthNotices: true, limit: 1
221
+ }).count > 0;
222
+ const messages = globalPage?.messages ?? [];
223
+ const taskPage = taskId === undefined ? null : store.queryContextRecords(taskId, {
224
+ family: "task-message", queuedForRole: roleName, unhandledMessages: true, limit: 8
225
+ });
226
+ const taskMessages = taskPage?.records.map(record => record.value) ?? [];
227
+ const host = hosts.find(item => item.taskId === taskId && item.roleName === roleName
228
+ && item.attemptId === turn?.attemptId && item.nativeSessionId === session?.nativeSessionId);
229
+ let hostObservation = host
230
+ ? { state: host.state, observedAt: host.observedAt, reconciliation: host.reconciliation }
231
+ : { state: "unobserved", observedAt: null, reconciliation: null };
232
+ if (taskId === undefined && turn?.status === "accepted") {
233
+ // Reuse existing Global Host failure evidence, never probe a second time.
234
+ const failure = store.queryGlobalRoleMessages(roleName, {
235
+ systemBodyPrefix: "Yui Global failure evidence ", descending: true, limit: 1
236
+ }).messages[0];
237
+ if (failure) {
238
+ const facts = JSON.parse(failure.body.slice(failure.body.indexOf("\n") + 1));
239
+ if (facts.reason === "host-terminal-unobservable" && facts.attemptId === turn.attemptId
240
+ && facts.nativeSessionId === session?.nativeSessionId && failure.createdAt >= turn.updatedAt) {
241
+ hostObservation = { state: typeof facts.hostState === "string" ? facts.hostState : "unobservable",
242
+ observedAt: failure.createdAt, reconciliation: facts.cause ?? null };
243
+ }
244
+ }
245
+ }
246
+ const count = batches.reduce((n, batch) => n + batch.requestCount, 0)
247
+ + (globalPage?.count ?? 0) + (taskPage?.count ?? 0);
248
+ const answeredInputs = refs.flatMap(ref => {
249
+ if (ref.type !== "input")
250
+ return [];
251
+ return store.getInputRequest(ref.taskId, ref.id)?.status === "answered" ? [`${ref.taskId}/${ref.id}`] : [];
252
+ });
253
+ // Only explicit matching blocked refs constitute a user wait. An unrelated
254
+ // open InputRequest must not hide an answered input stuck in the same Task.
255
+ const waitingInputs = taskId === undefined || roleName === "leader" ? [] :
256
+ store.listOpenInputRequests([taskId]).filter(request => request.blockedRefs.some(ref => ref.type === "run" && refs.some(queued => queued.type === "run" && queued.id === ref.id)
257
+ || ref.type === "work-item" && taskMessages.some(message => message.recipient?.workItemId === ref.id)))
258
+ .map(request => request.id);
259
+ const facts = {
260
+ count, handling, oldestAt: oldest([...batches.map(batch => batch.firstQueuedAt), globalPage?.oldestCreatedAt,
261
+ taskMessages[0]?.createdAt]),
262
+ processingAt: handlingOnly && count === 0 ? null : mailbox?.processing?.startedAt ?? null,
263
+ blocked: mailbox?.processing != null && wake?.status !== "consumed"
264
+ && turn?.status !== "accepted"
265
+ || messages.some(message => message.notDelivered !== undefined
266
+ || message.control?.outcome === "delivery-unknown" || message.control?.outcome === "pending")
267
+ || taskMessages.some(message => message.continuation?.notDeliveredReason !== undefined)
268
+ || ["idle", "failed", "exited", "unobservable"].includes(hostObservation.state)
269
+ && hostObservation.reconciliation !== "terminal-republished",
270
+ stopped: role?.enabled === false || session?.endReason === "stopped"
271
+ || task !== null && (!["active", "draft"].includes(task.status) || task.executionGate.state !== "enabled")
272
+ || binding?.authority.owner === "human" || binding?.goal?.status === "paused"
273
+ || waitingInputs.length > 0
274
+ || hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget(owner))),
275
+ turnStatus: turn?.status,
276
+ ...(currentBinding && providerRetryPending(binding)
277
+ && binding.retry.nativeSessionId === session.nativeSessionId
278
+ && binding.retry.authorityEpoch === binding.authority.epoch ? { retryDeadline: binding.retry.deadline } : {})
279
+ };
280
+ const classification = classifyMessageHealth(facts, now);
281
+ const readBase = taskId ? `yui task role session inspect ${arg(taskId)} ${arg(roleName)}`
282
+ : `yui config role show ${arg(roleName)}`;
283
+ const observation = {
284
+ ...facts, ...classification, target, observedAt: now.toISOString(),
285
+ session: session?.nativeSessionId ?? null, agentId: session?.agentId ?? null, hostObservation,
286
+ attemptId: turn?.attemptId ?? null, nativeTurnId: turn?.nativeTurnId ?? null,
287
+ host: binding?.authority.holderId ?? null, authority: binding?.authority.owner ?? null,
288
+ retry: binding?.retry ? { status: binding.retry.status, deadline: binding.retry.deadline,
289
+ attempts: binding.retry.attempts } : null,
290
+ wakeId, batchId: mailbox?.processing?.batchId ?? null, processingOwner: mailbox?.processing?.owner ?? null,
291
+ deliveryEvidence: delivery ? { outcome: delivery.payload.outcome ?? "unreported",
292
+ detail: delivery.payload.detail ?? null,
293
+ read: `yui task event show ${arg(taskId)} ${arg(delivery.id)}` } : null,
294
+ pendingCount: (mailbox?.pending?.requestCount ?? 0) + (globalPage?.count ?? 0) + (taskPage?.count ?? 0),
295
+ processingCount: mailbox?.processing?.batch.requestCount ?? 0,
296
+ pendingSince: mailbox?.pending?.firstQueuedAt ?? globalPage?.oldestCreatedAt ?? taskMessages[0]?.createdAt ?? null,
297
+ waitingInputs,
298
+ refs: [...refs.slice(0, 8), ...messages.map(message => ({
299
+ messageId: message.id, control: message.control ?? null, notDelivered: message.notDelivered ?? null
300
+ })), ...taskMessages.map(message => ({
301
+ messageId: message.id, recipient: message.recipient, continuation: message.continuation ?? null
302
+ }))], omittedRefs: Math.max(0, refs.length - 8) + Math.max(0, (globalPage?.count ?? 0) - messages.length)
303
+ + Math.max(0, (taskPage?.count ?? 0) - taskMessages.length),
304
+ answeredInputs: answeredInputs.slice(0, 8),
305
+ reads: [readBase, ...(wakeId && taskId ? [`yui task wake show ${arg(taskId)} ${arg(wakeId)}`] : []),
306
+ ...messages.map(message => `yui role message show ${arg(roleName)} ${arg(message.id)}`),
307
+ ...taskMessages.map(message => `yui task message show ${arg(`${taskId}/${message.id}`)}`)]
308
+ };
309
+ return { ...observation, recovery: recoveryGuidance(observation) };
310
+ }
311
+ /** Low-rate Controller observation, not an Agent loop. All decisions use current
312
+ * records inside each short transaction; evidence + queue admission are atomic.
313
+ * Open Event/Message content carries audit facts; no new storage envelope/index
314
+ * or independently writable scheduler state is introduced. */
315
+ export function reconcileMessageHealth(store, now = new Date(), hosts = []) {
316
+ const targets = store.listTasks().filter(task => task.status !== "archived").flatMap(task => store.listRoles(task.id).map(role => ({ taskId: task.id, roleName: role.name })));
317
+ targets.push(...store.listGlobalRoles().map(role => ({ roleName: role.name })));
318
+ const items = [];
319
+ for (const target of targets) {
320
+ store.transaction(tx => {
321
+ const observation = observe(tx, target, now, hosts);
322
+ let saved = latest(tx, target);
323
+ const previous = saved?.evidence;
324
+ const active = previous !== undefined && previous.phase !== "recovered";
325
+ const growing = active && observation.state === "warning" && observation.ageMs >= 15 * 60_000
326
+ && observation.count >= Math.max(8, previous.initialCount * 2);
327
+ const desired = growing ? "critical" : observation.state;
328
+ const phase = desired === "warning" || desired === "critical"
329
+ ? (!active || desired === "critical" && previous.phase === "warning" ? desired : null)
330
+ : desired === "clear" && active ? "recovered" : null;
331
+ if (phase !== null) {
332
+ saved = record(tx, target, {
333
+ phase, firstAt: active ? previous.firstAt : now.toISOString(), at: now.toISOString(),
334
+ initialCount: active ? previous.initialCount : observation.count,
335
+ peakCount: Math.max(active ? previous.peakCount : 0, observation.count),
336
+ previous: saved?.id ?? null, observation
337
+ }, now);
338
+ }
339
+ if (saved !== null)
340
+ notify(tx, saved, observation, now);
341
+ if (observation.count > 0 || observation.processingAt !== null || saved !== null) {
342
+ items.push({ ...observation, evidence: saved?.read ?? null, alert: alertStatus(tx, saved),
343
+ incident: saved ? { phase: saved.evidence.phase, firstAt: saved.evidence.firstAt,
344
+ latestEvidenceAt: saved.evidence.at, initialCount: saved.evidence.initialCount } : null });
345
+ }
346
+ });
347
+ }
348
+ // Status is an O(1) cached bounded view; exact originals remain independently readable.
349
+ const priority = (state) => state === "critical" ? 0 : state === "warning" ? 1 : state === "clear" ? 3 : 2;
350
+ items.sort((a, b) => priority(a.state) - priority(b.state) || b.ageMs - a.ageMs);
351
+ return { observedAt: now.toISOString(), warningAfterMs: MESSAGE_HEALTH_WARNING_MS,
352
+ criticalAfterMs: MESSAGE_HEALTH_CRITICAL_MS, total: items.length, omitted: Math.max(0, items.length - 64),
353
+ items: items.slice(0, 64) };
354
+ }
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { ControllerDiagnosticJournal } from "./diagnosticJournal.js";
3
+ import { reconcileMessageHealth } from "./messageHealth.js";
3
4
  import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
4
5
  import { prepareNativeHostSteer } from "./nativeHostInput.js";
5
6
  import { archiveLeaderTask } from "../task/leaderArchive.js";
@@ -583,6 +584,7 @@ async function createFileTaskControllerRuntime(home, options) {
583
584
  const continuationReconciler = options.continuationMetadata === undefined
584
585
  ? undefined
585
586
  : new ProviderContinuationReconciliationService(store, schedulerStore, options.continuationMetadata, options.onError);
587
+ let messageHealthHosts = [];
586
588
  const running = await startFileTaskController(home, workerMaintenance
587
589
  ? workerSchedulerStore(schedulerStore, asyncStoreClient)
588
590
  : schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
@@ -627,7 +629,13 @@ async function createFileTaskControllerRuntime(home, options) {
627
629
  }
628
630
  }),
629
631
  jobSupervisor,
630
- hostTerminalRecovery: () => recoverTaskHostTerminals(home, store, options.onError ?? (() => undefined)),
632
+ messageHealth: async (now) => workerMaintenance
633
+ ? await asyncStoreClient.invokeObserver("reconcileMessageHealth", [now, messageHealthHosts])
634
+ : reconcileMessageHealth(store, now, messageHealthHosts),
635
+ hostTerminalRecovery: async () => {
636
+ messageHealthHosts = [];
637
+ messageHealthHosts = await recoverTaskHostTerminals(home, store, options.onError ?? (() => undefined));
638
+ },
631
639
  globalInputDelivery: (deliveryOptions) => deliverGlobalInputs(home, store, async (roleName) => {
632
640
  await lifecycleDispatcher("runtime.ensure-role-session", { scope: "global", roleName });
633
641
  }, options.onError ?? (() => undefined), deliveryOptions),
@@ -69,6 +69,7 @@ export class RuntimeLaunchCoordinator {
69
69
  }
70
70
  }
71
71
  let preflightObserved = beforeHostStart === undefined;
72
+ let generatedTitle;
72
73
  const observePreflight = (preflight) => {
73
74
  if (preflightObserved || !sameOwner(preflight.owner, request.owner)
74
75
  || preflight.runId !== request.runId
@@ -80,6 +81,7 @@ export class RuntimeLaunchCoordinator {
80
81
  }
81
82
  assertLaunchCurrent();
82
83
  preflightObserved = true;
84
+ generatedTitle = preflight.generatedTitle;
83
85
  beforeHostStart?.(preflight);
84
86
  };
85
87
  const shared = {
@@ -94,7 +96,9 @@ export class RuntimeLaunchCoordinator {
94
96
  };
95
97
  // Even without a startup-facts consumer, recheck after asynchronous Host
96
98
  // validation and immediately before creating the process.
97
- const callback = beforeHostStart === undefined ? () => assertLaunchCurrent() : observePreflight;
99
+ const callback = beforeHostStart === undefined
100
+ ? (preflight) => { assertLaunchCurrent(); generatedTitle = preflight.generatedTitle; }
101
+ : observePreflight;
98
102
  if (request.mode === "resume" && !request.nativeSessionId) {
99
103
  throw new Error("Restoring a Session requires its native session id.");
100
104
  }
@@ -109,11 +113,18 @@ export class RuntimeLaunchCoordinator {
109
113
  throw new Error("Session host returned facts inconsistent with the requested launch.");
110
114
  }
111
115
  if (binding.nativeSessionId !== undefined) {
116
+ // A live Host may reuse its Provider without applying the new plan.
117
+ // Codex reports the actual naming effect; other adapters can only name
118
+ // at creation and must not overwrite native user names on resume.
119
+ const recordedTitle = request.adapterId === "codex"
120
+ ? binding.appliedGeneratedTitle
121
+ : request.mode === "new" ? generatedTitle : undefined;
112
122
  this.sessions.recordLaunchedRuntimeNativeSession({
113
123
  owner: request.owner,
114
124
  agentId: request.agentId,
115
125
  adapterId: request.adapterId,
116
126
  nativeSessionId: binding.nativeSessionId,
127
+ ...(recordedTitle === undefined ? {} : { generatedTitle: recordedTitle }),
117
128
  effective
118
129
  }, assertLaunchCurrent, this.options.now?.() ?? new Date());
119
130
  }