@zq-silk/yui 2.17.0 → 2.19.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 (73) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +14 -4
  3. package/dist/commands/durableJobCommands.js +50 -3
  4. package/dist/commands/globalRoleCommands.js +18 -12
  5. package/dist/commands/inboxCommands.js +1 -0
  6. package/dist/commands/operatorCommands.js +10 -2
  7. package/dist/controlPlaneCli.js +21 -4
  8. package/dist/controller/clientRuntime.js +34 -13
  9. package/dist/controller/controller.js +45 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +143 -101
  12. package/dist/controller/globalInputDelivery.js +6 -2
  13. package/dist/controller/globalRuntimeAttention.js +11 -3
  14. package/dist/controller/jobControl.js +1 -1
  15. package/dist/controller/jobDefinitionControl.js +24 -13
  16. package/dist/controller/jobSupervisor.js +4 -0
  17. package/dist/controller/messageHealth.js +354 -0
  18. package/dist/controller/runtime.js +75 -5
  19. package/dist/controller/taskHostTerminalRecovery.js +11 -0
  20. package/dist/controller/workerSchedulerStore.js +24 -0
  21. package/dist/core/boundedRpc.js +4 -0
  22. package/dist/core/controllerServer.js +1 -1
  23. package/dist/core/controllerTelemetry.js +92 -19
  24. package/dist/job/globalShell.js +51 -0
  25. package/dist/job/jobBusinessResult.js +4 -3
  26. package/dist/job/jobDefinition.js +6 -1
  27. package/dist/job/jobRunner.js +9 -0
  28. package/dist/job/scheduleService.js +2 -0
  29. package/dist/message/globalInboxRecovery.js +30 -19
  30. package/dist/message/globalProviderRetry.js +7 -1
  31. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  32. package/dist/runtime/globalSessionHealth.js +39 -0
  33. package/dist/scheduler/roleRunStall.js +10 -10
  34. package/dist/storage/contextRecords.js +16 -0
  35. package/dist/storage/persistenceWorker.js +41 -19
  36. package/dist/storage/sqliteSchema.js +35 -11
  37. package/dist/storage/sqliteStore.js +102 -8
  38. package/dist/storage/storageVersions.js +1 -1
  39. package/dist/storage/storeRpc.js +25 -11
  40. package/dist/storage/taskReadRevisionSchema.js +39 -0
  41. package/dist/web/assets/client/app/catalog.js +0 -29
  42. package/dist/web/assets/client/app/jobs.js +115 -0
  43. package/dist/web/assets/client/app/main.js +17 -6
  44. package/dist/web/assets/client/app/manage.js +1 -0
  45. package/dist/web/assets/client/app/settings.js +1 -5
  46. package/dist/web/assets/client/i18n/en.js +203 -18
  47. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  48. package/dist/web/assets/client/index.js +2 -0
  49. package/dist/web/assets/client/layout/manageShell.js +61 -15
  50. package/dist/web/assets/client/layout/router.js +2 -1
  51. package/dist/web/assets/client/lib/prefs.js +1 -1
  52. package/dist/web/assets/client/views/jobs.js +1042 -160
  53. package/dist/web/assets/client/views/sidebar.js +3 -0
  54. package/dist/web/assets/shared/manageNav.js +3 -1
  55. package/dist/web/assets/shell/appRail.js +2 -1
  56. package/dist/web/assets/shell/manage.js +7 -2
  57. package/dist/web/assets/shell/models.js +4 -3
  58. package/dist/web/assets/shell/settingsContent.js +0 -3
  59. package/dist/web/assets/shell/sidebar.js +0 -1
  60. package/dist/web/assets/styles/views/jobs.js +301 -12
  61. package/dist/web/assets/styles/views/sidebar.js +1 -2
  62. package/dist/web/webServer.js +28 -1
  63. package/docs/global-shell-jobs.md +78 -0
  64. package/docs/job-runs.md +6 -3
  65. package/docs/managed-turn-and-session-runtime.md +170 -0
  66. package/docs/managed-turn-and-session-runtime.zh-CN.md +116 -0
  67. package/docs/release-workflow.md +63 -0
  68. package/docs/release-workflow.zh-CN.md +44 -0
  69. package/docs/storage-baseline.md +14 -1
  70. package/docs/storage-baseline.zh-CN.md +6 -1
  71. package/docs/testing/verification-levels.md +2 -2
  72. package/docs/testing/verification-levels.zh-CN.md +2 -2
  73. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { requireKnownFields } from "../domain/validation.js";
5
5
  import { createJobDefinition, reviseJobDefinition, jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
6
6
  import { acknowledgeUnknownDurableJob, requestDurableJobCancel, validateDurableJob, JOB_RUNNER_IMPLEMENTATION, executionNamespace } from "../job/durableJob.js";
7
7
  import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
8
+ import { prepareGlobalShell } from "../job/globalShell.js";
8
9
  import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
9
10
  import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
10
11
  import { createFileArtifactPort } from "./jobSupervisor.js";
@@ -38,7 +39,7 @@ export function authorizeScope(store, scope, caller, allowPublic = false) {
38
39
  assertCallerAuthorized(store, caller, scope.kind === "task" ? scope.taskId : "");
39
40
  }
40
41
  function taskParams(job, caller, requestId) {
41
- if (job.scope.kind !== "task" || job.configuration.kind !== "shell")
42
+ if (job.scope.kind !== "task" || job.configuration.kind !== "shell" || !("owner" in job.configuration))
42
43
  throw new Error("Not a Task Job.");
43
44
  return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
44
45
  }
@@ -60,8 +61,13 @@ export function authorizeDefinition(store, job, caller, write) {
60
61
  assertNonSecretJobInput(params);
61
62
  }
62
63
  else if (write) {
64
+ if (job.configuration.kind === "shell") {
65
+ prepareGlobalShell(job.configuration, "/job-validation");
66
+ assertNonSecretJobInput({ ...job.configuration, requestId: "global-shell-definition" });
67
+ return;
68
+ }
63
69
  if (job.configuration.kind !== "builtin")
64
- throw new Error("Global Jobs require builtin actions.");
70
+ throw new Error("Not a global process Job.");
65
71
  prepareBuiltinJob(job.configuration, "/");
66
72
  if (job.configuration.action === "project.refresh") {
67
73
  if (job.builtin)
@@ -190,18 +196,21 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
190
196
  }, now);
191
197
  return { run: result.job, created: result.created };
192
198
  }
193
- if (job.configuration.kind !== "builtin")
194
- throw new Error("Global Jobs require builtin actions.");
199
+ if (job.configuration.kind !== "builtin" && job.configuration.kind !== "shell")
200
+ throw new Error("Not a global process Job.");
195
201
  const id = `job-${randomUUID()}`;
196
202
  const locator = `artifacts/jobs/global/${id}`;
197
- const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
203
+ const config = job.configuration;
204
+ const shell = config.kind === "shell";
205
+ const spec = shell ? prepareGlobalShell(config, join(home, locator, "workspace"))
206
+ : prepareBuiltinJob(config, join(home, locator), { home, runId: id });
198
207
  const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
199
208
  let run = {
200
209
  schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
201
- trigger: structuredClone(trigger), action: job.configuration.action,
210
+ trigger: structuredClone(trigger), action: shell ? "shell.exec" : config.action,
202
211
  ...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
203
- actionInput: structuredClone(job.configuration.input),
204
- ...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
212
+ actionInput: structuredClone(shell ? config : config.input),
213
+ ...(!shell && config.projectId !== undefined ? { projectId: config.projectId } : {}),
205
214
  ...spec, status: "queued", artifactsLocator: locator,
206
215
  idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
207
216
  operation: {
@@ -280,10 +289,12 @@ export function authorizeGlobalJobStart(store, run) {
280
289
  const projects = run.action === "project.refresh" ? store.listProjects() : [];
281
290
  const home = store.rootDirectory();
282
291
  try {
283
- const compiled = prepareBuiltinJob({
284
- kind: "builtin", action: run.action, input: run.actionInput,
285
- ...(run.projectId === undefined ? {} : { projectId: run.projectId })
286
- }, run.workspace, { home, runId: run.id });
292
+ const compiled = run.action === "shell.exec"
293
+ ? prepareGlobalShell(run.actionInput, join(home, "artifacts/jobs/global", run.id, "workspace"))
294
+ : prepareBuiltinJob({
295
+ kind: "builtin", action: run.action, input: run.actionInput,
296
+ ...(run.projectId === undefined ? {} : { projectId: run.projectId })
297
+ }, run.workspace, { home, runId: run.id });
287
298
  if (run.action === "project.refresh") {
288
299
  const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
289
300
  if (definition?.builtin)
@@ -297,7 +308,7 @@ export function authorizeGlobalJobStart(store, run) {
297
308
  throw new Error("Project refresh Run workspace does not match its artifact directory.");
298
309
  }
299
310
  }
300
- if (JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
311
+ if (compiled.workspace !== run.workspace || JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
301
312
  || JSON.stringify(compiled.env) !== JSON.stringify(run.env) || compiled.head !== run.head) {
302
313
  throw new Error("Global JobRun does not match its controlled action.");
303
314
  }
@@ -199,6 +199,8 @@ export class DurableJobSupervisor {
199
199
  return;
200
200
  }
201
201
  const spec = {
202
+ ...(!("taskId" in job) && "action" in job && job.action === "shell.exec"
203
+ ? { files: job.actionInput.files } : {}),
202
204
  jobId: job.id,
203
205
  ...("taskId" in job ? { taskId: job.taskId } : { scope: "global" }),
204
206
  workspace: job.workspace,
@@ -386,6 +388,8 @@ export class DurableJobSupervisor {
386
388
  return fallback;
387
389
  if (job.action === "system.noop")
388
390
  return { ...fallback, data: { action: "system.noop" } };
391
+ if (job.action === "shell.exec")
392
+ return fallback;
389
393
  const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
390
394
  if (raw !== null && raw !== undefined) {
391
395
  try {
@@ -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,4 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { ControllerDiagnosticJournal } from "./diagnosticJournal.js";
3
+ import { reconcileMessageHealth } from "./messageHealth.js";
4
+ import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
2
5
  import { prepareNativeHostSteer } from "./nativeHostInput.js";
3
6
  import { archiveLeaderTask } from "../task/leaderArchive.js";
4
7
  import { TaskWorkspaceCoordinator } from "../repository/taskWorkspaceCoordinator.js";
@@ -61,7 +64,6 @@ import { authorizeJobStart } from "./jobControl.js";
61
64
  import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
62
65
  import { reconcileTaskDelegation } from "./jobTaskDelegationControl.js";
63
66
  import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
64
- import { reconcileProjectSync } from "../job/projectSync.js";
65
67
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
66
68
  import { createProviderRetryHooks } from "./providerRetryDelivery.js";
67
69
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
@@ -72,8 +74,54 @@ import { AsyncRuntimeEventProcessor, createAsyncRuntimeObserver, FileRuntimeEven
72
74
  import { RuntimeLaunchCoordinator } from "./runtimeLaunchCoordinator.js";
73
75
  import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
74
76
  import { readTaskAgentCapabilities } from "./agentCapabilities.js";
77
+ import { workerSchedulerStore } from "./workerSchedulerStore.js";
78
+ import { deserializeError } from "../core/boundedRpc.js";
75
79
  /** Production composition root for the current SQLite TaskStore + tmux Controller. */
76
80
  export async function startFileTaskControllerRuntime(home, options = {}) {
81
+ const diagnostics = new ControllerDiagnosticJournal(home);
82
+ let timer;
83
+ const closeDiagnostics = async () => {
84
+ if (timer !== undefined)
85
+ clearInterval(timer);
86
+ await diagnostics.close();
87
+ };
88
+ try {
89
+ const running = await createFileTaskControllerRuntime(home, {
90
+ ...options,
91
+ diagnosticMetrics: () => diagnostics.metrics(),
92
+ onError: error => { diagnostics.error(error); options.onError?.(error); }
93
+ });
94
+ let expectedAt = monotonicMilliseconds() + 250;
95
+ timer = setInterval(() => {
96
+ const now = monotonicMilliseconds();
97
+ const lagMs = Math.max(0, now - expectedAt);
98
+ expectedAt = now + 250;
99
+ diagnostics.observe({
100
+ ...running.runtime.runtimeMetrics(),
101
+ commands: {
102
+ routes: running.server.commandObserver.snapshot(),
103
+ eventLoopDelay: running.server.eventLoopDelay.snapshot()
104
+ }
105
+ }, lagMs);
106
+ }, 250);
107
+ timer.unref();
108
+ const closed = running.closed.finally(closeDiagnostics);
109
+ return { ...running, closed, close: async () => {
110
+ try {
111
+ await running.close();
112
+ }
113
+ finally {
114
+ await closeDiagnostics();
115
+ }
116
+ } };
117
+ }
118
+ catch (error) {
119
+ diagnostics.error(error);
120
+ await closeDiagnostics();
121
+ throw error;
122
+ }
123
+ }
124
+ async function createFileTaskControllerRuntime(home, options) {
77
125
  // The current Home always uses SQLite. The persistence worker is enabled by
78
126
  // default; YUI_STORE_WORKER=0/false keeps the same database in-process.
79
127
  const useWorker = resolveStoreWorkerEnabledForHome(home, options.environment ?? process.env);
@@ -111,6 +159,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
111
159
  closeTelemetry = () => telemetry?.sink.close() ?? Promise.resolve();
112
160
  const schedulerStore = options.schedulerStore
113
161
  ?? new FileSchedulerStoreAdapter(store, telemetry);
162
+ // Injected stores/adapters retain their owner and semantics. Only the
163
+ // production SQLite adapter shares the worker's exact Home and behavior.
164
+ const workerMaintenance = asyncStoreClient !== undefined
165
+ && options.store === undefined && options.schedulerStore === undefined;
114
166
  const domainIdentity = options.domainIdentity
115
167
  ?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
116
168
  const planner = options.planner ?? new FileRoleLaunchPlanner(home, store, {
@@ -532,7 +584,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
532
584
  const continuationReconciler = options.continuationMetadata === undefined
533
585
  ? undefined
534
586
  : new ProviderContinuationReconciliationService(store, schedulerStore, options.continuationMetadata, options.onError);
535
- const running = await startFileTaskController(home, schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
587
+ let messageHealthHosts = [];
588
+ const running = await startFileTaskController(home, workerMaintenance
589
+ ? workerSchedulerStore(schedulerStore, asyncStoreClient)
590
+ : schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
536
591
  ? readTaskAgentCapabilities(store, catalogs, params)
537
592
  : method === "web.start" || method === "web.stop" || method === "web.status"
538
593
  ? web.dispatch(method, params) : lifecycleDispatcher(method, params), {
@@ -551,6 +606,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
551
606
  ?? resolveRuntimeHealth(durableConfig.runtimeHealth).diagnosticAfterMs,
552
607
  now: options.now,
553
608
  onError: options.onError,
609
+ diagnosticMetrics: options.diagnosticMetrics,
554
610
  lifecycleHost,
555
611
  providerRetry: createProviderRetryHooks(home, store, {
556
612
  now: options.now,
@@ -573,7 +629,13 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
573
629
  }
574
630
  }),
575
631
  jobSupervisor,
576
- 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
+ },
577
639
  globalInputDelivery: (deliveryOptions) => deliverGlobalInputs(home, store, async (roleName) => {
578
640
  await lifecycleDispatcher("runtime.ensure-role-session", { scope: "global", roleName });
579
641
  }, options.onError ?? (() => undefined), deliveryOptions),
@@ -581,8 +643,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
581
643
  jobDefinitions: createJobDefinitionControl(store, home),
582
644
  schedules: {
583
645
  control: createScheduleControl(store, home),
584
- reconcile: now => {
585
- reconcileProjectSync(store, now, options.onError);
646
+ reconcile: async (now) => {
647
+ const errors = workerMaintenance
648
+ ? await asyncStoreClient.invokeObserver("reconcileProjectSync", [now])
649
+ : schedulerStore.reconcileProjectSync(now);
650
+ for (const error of errors)
651
+ options.onError?.(deserializeError(error));
586
652
  reconcileSchedules(store, home, now, options.onError);
587
653
  }
588
654
  },
@@ -600,6 +666,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
600
666
  ?? (useWorker && asyncStoreClient !== undefined
601
667
  ? new AsyncRuntimeEventProcessor(runtimeEventInbox, createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)))
602
668
  : new FileRuntimeEventProcessor(runtimeEventInbox, schedulerStore)),
669
+ persistenceMetrics: () => ({
670
+ worker: asyncStoreClient?.observationMetrics() ?? null,
671
+ database: store instanceof SqliteTaskStore ? store.storageObservationMetrics() : null
672
+ }),
603
673
  runtimeObserver: options.runtimeObserver
604
674
  ?? new AgentRuntimeObserver(store, runtimeEventInbox),
605
675
  domainIdentity,
@@ -3,6 +3,7 @@ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.
3
3
  import { providerRetryPending } from "../runtime/providerRetry.js";
4
4
  /** Existing full reconciliation pass only; no new timer, input or Session. */
5
5
  export async function recoverTaskHostTerminals(home, store, onError, reconcile = reconcileAgentHostTurn) {
6
+ const observations = [];
6
7
  for (const candidate of store.listRuntimeSessionCandidates({ scope: "task" })) {
7
8
  const owner = candidate.owner;
8
9
  if (owner.scope !== "task")
@@ -20,14 +21,24 @@ export async function recoverTaskHostTerminals(home, store, onError, reconcile =
20
21
  attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId,
21
22
  authority: { epoch: binding.authority.epoch, owner: "controller", holderId: binding.authority.holderId }
22
23
  } });
24
+ observations.push({ taskId: owner.taskId, roleName: owner.roleName,
25
+ nativeSessionId: currentProviderConversation(binding).conversationId, attemptId: turn.attemptId,
26
+ state: result.snapshot.state, reconciliation: result.reconciliation ?? null,
27
+ outcome: result.outcome, observedAt: new Date().toISOString() });
23
28
  if (result.outcome === "rejected" || result.reconciliation === "unsupported") {
24
29
  throw new Error(result.failure?.detail
25
30
  ?? "Host terminal reconciliation is unsupported; retain the original input and inspect Host/Inbox evidence.");
26
31
  }
27
32
  }
28
33
  catch (error) {
34
+ if (!observations.some(item => item.taskId === owner.taskId && item.roleName === owner.roleName)) {
35
+ observations.push({ taskId: owner.taskId, roleName: owner.roleName,
36
+ nativeSessionId: currentProviderConversation(binding).conversationId, attemptId: turn.attemptId,
37
+ state: "unobservable", reconciliation: null, outcome: "unavailable", observedAt: new Date().toISOString() });
38
+ }
29
39
  onError(new Error(`Task Host terminal recovery ${owner.taskId}/${owner.roleName}/${turn.attemptId}: `
30
40
  + `${error instanceof Error ? error.message : String(error)} No input was replayed.`, { cause: error }));
31
41
  }
32
42
  }
43
+ return observations;
33
44
  }