@zq-silk/yui 2.21.0 → 2.22.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 (125) hide show
  1. package/README.md +5 -0
  2. package/dist/cli/commandCatalog.js +85 -25
  3. package/dist/cli/dynamicCompletion.js +4 -1
  4. package/dist/cli/helpRenderer.js +12 -1
  5. package/dist/cli/interactionPolicy.js +2 -0
  6. package/dist/cli/invocationRouter.js +5 -5
  7. package/dist/cli/roleWizard.js +5 -1
  8. package/dist/commands/globalRoleCommands.js +33 -8
  9. package/dist/commands/inboxCommands.js +39 -12
  10. package/dist/commands/operatorCommands.js +2 -5
  11. package/dist/commands/releaseCommands.js +16 -2
  12. package/dist/commands/roleConfiguration.js +25 -2
  13. package/dist/commands/sessionCommands.js +5 -2
  14. package/dist/commands/taskCommands.js +217 -64
  15. package/dist/commands/taskContextCommand.js +26 -4
  16. package/dist/commands/taskFactCommands.js +15 -2
  17. package/dist/commands/taskIntegrationCommands.js +3 -3
  18. package/dist/commands/taskRoleRuntimeStatus.js +15 -5
  19. package/dist/commands/textInput.js +2 -2
  20. package/dist/context/contextFamilies.js +10 -0
  21. package/dist/context/currentSkills.js +6 -4
  22. package/dist/context/runContextPack.js +44 -5
  23. package/dist/context/runInputContract.js +1 -1
  24. package/dist/context/sessionBootstrapManifest.js +7 -5
  25. package/dist/context/sessionSkillNotice.js +53 -0
  26. package/dist/context/taskContext.js +100 -16
  27. package/dist/controlPlaneCli.js +17 -7
  28. package/dist/controller/agentRuntimeObserver.js +57 -12
  29. package/dist/controller/clientRuntime.js +37 -8
  30. package/dist/controller/controller.js +3 -1
  31. package/dist/controller/diagnosticJournal.js +10 -2
  32. package/dist/controller/fileSchedulerStoreAdapter.js +72 -24
  33. package/dist/controller/messageHealth.js +4 -3
  34. package/dist/controller/providerRetryAdmission.js +15 -1
  35. package/dist/controller/providerRetryDelivery.js +18 -2
  36. package/dist/controller/runtime.js +15 -5
  37. package/dist/controller/runtimeEventInbox.js +107 -13
  38. package/dist/controller/runtimeEventProcessor.js +3 -3
  39. package/dist/controller/taskRunRecovery.js +153 -0
  40. package/dist/core/controllerClient.js +16 -2
  41. package/dist/core/controllerServer.js +14 -5
  42. package/dist/core/controllerTelemetry.js +51 -5
  43. package/dist/errors/cliFailure.js +14 -5
  44. package/dist/executor/agentAdapter.js +32 -4
  45. package/dist/executor/agentConfigurationFields.js +2 -0
  46. package/dist/integration/gitIntegrationService.js +3 -2
  47. package/dist/integration/integrationSourceApplication.js +12 -1
  48. package/dist/interaction/notificationPresentation.js +11 -16
  49. package/dist/interaction/operatorPresentation.js +1 -1
  50. package/dist/message/messageContinuation.js +3 -1
  51. package/dist/output/boundedRead.js +36 -7
  52. package/dist/output/rolePresentation.js +3 -1
  53. package/dist/runtime/agentHost.js +12 -3
  54. package/dist/runtime/codexAppServerRuntime.js +110 -24
  55. package/dist/runtime/codexInteractiveHost.js +46 -0
  56. package/dist/runtime/providerRetry.js +5 -2
  57. package/dist/runtime/providerRuntimeIdentity.js +1 -1
  58. package/dist/runtime/structuredProviderHost.js +32 -34
  59. package/dist/scheduler/wakeupQueue.js +10 -4
  60. package/dist/storage/sqliteSchema.js +9 -0
  61. package/dist/storage/storageVersions.js +1 -1
  62. package/dist/tmux/tmuxManager.js +4 -2
  63. package/dist/web/webSettings.js +2 -0
  64. package/docs/authorization.md +18 -3
  65. package/docs/cli-information-contract.md +228 -6
  66. package/docs/cli-information-contract.zh-CN.md +95 -5
  67. package/docs/managed-turn-and-session-runtime.md +82 -21
  68. package/docs/managed-turn-and-session-runtime.zh-CN.md +56 -13
  69. package/docs/provider-retry.md +11 -0
  70. package/docs/release-workflow.md +37 -0
  71. package/docs/release-workflow.zh-CN.md +28 -0
  72. package/docs/roles-and-configuration.md +28 -0
  73. package/docs/roles-and-configuration.zh-CN.md +21 -0
  74. package/docs/storage-baseline.md +4 -1
  75. package/docs/storage-baseline.zh-CN.md +1 -1
  76. package/docs/task-delivery.md +7 -0
  77. package/docs/task-delivery.zh-CN.md +5 -0
  78. package/docs/testing/review-evaluation.md +7 -7
  79. package/docs/testing/review-evaluation.zh-CN.md +7 -7
  80. package/docs/testing/verification-levels.md +45 -2
  81. package/docs/testing/verification-levels.zh-CN.md +21 -2
  82. package/i18n/README.zh-CN.md +6 -0
  83. package/package.json +1 -1
  84. package/skills/yui-leader/SKILL.md +98 -193
  85. package/skills/yui-leader/VERSION +1 -1
  86. package/skills/yui-leader/references/artifacts.md +41 -0
  87. package/skills/yui-leader/references/assignment-execution.md +140 -0
  88. package/skills/yui-leader/references/completion.md +79 -0
  89. package/skills/yui-leader/references/delivery-scope.md +26 -0
  90. package/skills/yui-leader/references/execution.md +16 -487
  91. package/skills/yui-leader/references/implementation.md +81 -0
  92. package/skills/yui-leader/references/planning.md +7 -45
  93. package/skills/yui-leader/references/review.md +90 -0
  94. package/skills/yui-leader/references/task-progress.md +23 -0
  95. package/skills/yui-leader/references/work-allocation.md +118 -0
  96. package/skills/yui-operator/SKILL.md +77 -333
  97. package/skills/yui-operator/VERSION +1 -1
  98. package/skills/yui-operator/references/body-transport.md +62 -0
  99. package/skills/yui-operator/references/configuration.md +47 -0
  100. package/skills/yui-operator/references/lifecycle.md +41 -0
  101. package/skills/yui-operator/references/progress.md +39 -0
  102. package/skills/yui-operator/references/routing.md +57 -0
  103. package/skills/yui-operator/references/submission.md +56 -0
  104. package/skills/yui-operator/references/task-delivery.md +1 -1
  105. package/skills/yui-operator/references/web-lifecycle.md +19 -0
  106. package/skills/yui-operator/scripts/verify-submission.mjs +37 -0
  107. package/skills/yui-runtime/SKILL.md +177 -531
  108. package/skills/yui-runtime/VERSION +1 -1
  109. package/skills/yui-runtime/references/archive.md +51 -0
  110. package/skills/yui-runtime/references/command-discovery.md +41 -0
  111. package/skills/yui-runtime/references/completed-followup.md +65 -0
  112. package/skills/yui-runtime/references/context-ordering.md +3 -34
  113. package/skills/yui-runtime/references/context-reads.md +61 -0
  114. package/skills/yui-runtime/references/dispatched-run.md +61 -0
  115. package/skills/yui-runtime/references/execution-failure.md +105 -0
  116. package/skills/yui-runtime/references/global-context.md +18 -0
  117. package/skills/yui-runtime/references/global-input-controls.md +44 -0
  118. package/skills/yui-runtime/references/host-custody.md +30 -0
  119. package/skills/yui-runtime/references/inbox-recovery.md +77 -0
  120. package/skills/yui-runtime/references/incremental-context.md +40 -0
  121. package/skills/yui-runtime/references/publication.md +4 -111
  122. package/skills/yui-runtime/references/recovery.md +32 -192
  123. package/skills/yui-runtime/references/session-identity.md +18 -0
  124. package/skills/yui-runtime/references/session-replacement.md +45 -0
  125. package/skills/yui-runtime/references/uncertain-execution.md +29 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { closeSync, constants, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { lstat, readFile, readdir } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { createRuntimeObservation, isRuntimeUsageEvidence } from "../runtime/runtimeObservation.js";
6
7
  import { boundedRunFailureDiagnostic, transportAgentResult } from "../domain/agentResultTransport.js";
@@ -19,6 +20,9 @@ export class FileRuntimeEventInbox {
19
20
  now;
20
21
  hooks;
21
22
  directory;
23
+ // Serialize this producer's async admissions, not the consumer/ACK path.
24
+ // No persistent index or cache: each scan retains the existing validation.
25
+ #asyncAdmission = Promise.resolve();
22
26
  constructor(home, now = () => new Date(), hooks = {}) {
23
27
  this.home = home;
24
28
  this.now = now;
@@ -26,6 +30,31 @@ export class FileRuntimeEventInbox {
26
30
  this.directory = join(home, RUNTIME_EVENT_DIRECTORY);
27
31
  }
28
32
  enqueueObservation(input, host) {
33
+ return this.publish(this.observationEvent(input, host));
34
+ }
35
+ /** Controller sampler ingress. The full usage dedup scan yields to control
36
+ * I/O; publication keeps the existing atomic fsync/link/conflict boundary.
37
+ * Callers must await before advancing cursor/state. Throughput remains O(U*E)
38
+ * and a scan still retains O(E) payload memory. */
39
+ enqueueObservationAsync(input, host) {
40
+ const event = this.observationEvent(input, host);
41
+ const admission = this.#asyncAdmission.then(async () => {
42
+ this.hooks.afterAdmission?.();
43
+ if (isRuntimeUsageEvidence(event.observation)) {
44
+ const existing = (await this.listAsync()).find(candidate => (candidate.type === "runtime-observation"
45
+ && candidate.taskId === event.taskId
46
+ && candidate.observation.eventId === event.observation.eventId));
47
+ if (existing !== undefined)
48
+ return {
49
+ event: existing, created: false
50
+ };
51
+ }
52
+ return this.publishUnlocked(event);
53
+ });
54
+ this.#asyncAdmission = admission.catch(() => undefined);
55
+ return admission;
56
+ }
57
+ observationEvent(input, host) {
29
58
  const observation = createRuntimeObservation(input);
30
59
  const scope = observation.fence.taskId === undefined ? "global" : "task";
31
60
  const event = Object.freeze({
@@ -40,7 +69,7 @@ export class FileRuntimeEventInbox {
40
69
  observation,
41
70
  ...(host === undefined ? {} : { host: readAgentHostObservationSource(host) })
42
71
  });
43
- return this.publish(event);
72
+ return event;
44
73
  }
45
74
  enqueueRunTerminal(input) {
46
75
  const normalized = normalizeNativeTurnTerminalInput(input);
@@ -89,6 +118,59 @@ export class FileRuntimeEventInbox {
89
118
  }
90
119
  return events.sort(compareRuntimeEvents);
91
120
  }
121
+ /** Controller consumers must not synchronously read an entire retained
122
+ * backlog before accepting control sockets. Keep the same validated,
123
+ * sorted snapshot; the fold/ACK batch and durable identities are unchanged. */
124
+ async listAsync() {
125
+ let names;
126
+ try {
127
+ names = await readdir(this.directory);
128
+ }
129
+ catch (error) {
130
+ if (isNodeError(error, "ENOENT"))
131
+ return [];
132
+ throw error;
133
+ }
134
+ const events = [];
135
+ for (const name of names.filter((entry) => entry.endsWith(".json"))) {
136
+ try {
137
+ const id = name.slice(0, -".json".length);
138
+ assertEventId(id);
139
+ const path = this.eventPath(id);
140
+ let metadata;
141
+ try {
142
+ metadata = await lstat(path);
143
+ }
144
+ catch (error) {
145
+ if (isNodeError(error, "ENOENT"))
146
+ continue;
147
+ throw error;
148
+ }
149
+ assertEventFile(metadata, id);
150
+ let content;
151
+ try {
152
+ content = await readFile(path, "utf8");
153
+ }
154
+ catch {
155
+ throw invalidEvent(`Runtime event JSON is invalid: ${id}`);
156
+ }
157
+ const event = parseEventFile(content, id);
158
+ events.push(event);
159
+ }
160
+ catch (error) {
161
+ if (!(error instanceof RuntimeEventInboxError)
162
+ || error.code !== "RUNTIME_EVENT_INVALID")
163
+ throw error;
164
+ try {
165
+ this.quarantine(name);
166
+ }
167
+ catch {
168
+ // Match list(): an invalid entry cannot block independent facts.
169
+ }
170
+ }
171
+ }
172
+ return events.sort(compareRuntimeEvents);
173
+ }
92
174
  read(id) {
93
175
  assertEventId(id);
94
176
  const path = this.eventPath(id);
@@ -101,23 +183,15 @@ export class FileRuntimeEventInbox {
101
183
  return null;
102
184
  throw error;
103
185
  }
104
- if (!metadata.isFile()
105
- || (metadata.mode & 0o777) !== 0o600
106
- || metadata.size > MAX_RUNTIME_EVENT_FILE_BYTES) {
107
- throw invalidEvent(`Runtime event file is invalid: ${id}`);
108
- }
109
- let value;
186
+ assertEventFile(metadata, id);
187
+ let content;
110
188
  try {
111
- value = JSON.parse(readFileSync(path, "utf8"));
189
+ content = readFileSync(path, "utf8");
112
190
  }
113
191
  catch {
114
192
  throw invalidEvent(`Runtime event JSON is invalid: ${id}`);
115
193
  }
116
- const event = parseRuntimeEvent(decodeInboxV1(value));
117
- if (event.id !== id || runtimeEventId(event.type, event) !== id) {
118
- throw invalidEvent(`Runtime event identity is invalid: ${id}`);
119
- }
120
- return event;
194
+ return parseEventFile(content, id);
121
195
  }
122
196
  /** Producer-local delivery diagnostics need only existence, never a read of
123
197
  * another version's payload or permission to quarantine another producer. */
@@ -252,6 +326,26 @@ export class FileRuntimeEventInbox {
252
326
  }
253
327
  }
254
328
  }
329
+ function assertEventFile(metadata, id) {
330
+ if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600
331
+ || metadata.size > MAX_RUNTIME_EVENT_FILE_BYTES) {
332
+ throw invalidEvent(`Runtime event file is invalid: ${id}`);
333
+ }
334
+ }
335
+ function parseEventFile(content, id) {
336
+ let value;
337
+ try {
338
+ value = JSON.parse(content);
339
+ }
340
+ catch {
341
+ throw invalidEvent(`Runtime event JSON is invalid: ${id}`);
342
+ }
343
+ const event = parseRuntimeEvent(decodeInboxV1(value));
344
+ if (event.id !== id || runtimeEventId(event.type, event) !== id) {
345
+ throw invalidEvent(`Runtime event identity is invalid: ${id}`);
346
+ }
347
+ return event;
348
+ }
255
349
  export class RuntimeEventInboxError extends Error {
256
350
  code;
257
351
  constructor(code, message) {
@@ -530,8 +530,8 @@ export function createAsyncRuntimeObserver(invoke) {
530
530
  /**
531
531
  * The async counterpart to {@link FileRuntimeEventProcessor}: folds immutable
532
532
  * Hook facts one at a time (awaiting the worker-hosted observer) before
533
- * acknowledging them. The inbox itself is file-based and stays on the main
534
- * thread; only the db-touching folds are proxied to the worker.
533
+ * acknowledging them. File reads are asynchronous so a retained backlog
534
+ * cannot hold the control event loop before the worker's bounded fold.
535
535
  */
536
536
  export class AsyncRuntimeEventProcessor {
537
537
  inbox;
@@ -549,7 +549,7 @@ export class AsyncRuntimeEventProcessor {
549
549
  const failed = [];
550
550
  let events;
551
551
  try {
552
- events = this.inbox.list();
552
+ events = this.inbox.listAsync === undefined ? this.inbox.list() : await this.inbox.listAsync();
553
553
  }
554
554
  catch (error) {
555
555
  return emptyDrainFailure(error);
@@ -0,0 +1,153 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { readRunContextSnapshot } from "../context/runContextPack.js";
3
+ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
4
+ /**
5
+ * An explicit current-owner retry may follow an undelivered message
6
+ * continuation, rather than the retry's original business input. Recover the
7
+ * native anchor from durable evidence only; never schedule here or renew its
8
+ * budget. Native latest-failed-Turn and background checks still run at submit.
9
+ */
10
+ export function taskRunRecovery(store, binding, current) {
11
+ if (!verifiedRecoveryChain(store, binding, current, false))
12
+ return undefined;
13
+ const { currentAttemptId: _attempt, successorRunId: _successor, successorReviewRoundId: _round, ...retained } = binding.retry;
14
+ return { ...retained, input: binding.run.input, status: "waiting", successorAutomatic: false,
15
+ previousRunId: current.id, failedAttemptId: binding.run.attemptId };
16
+ }
17
+ /** Read-only handling authority for the exact original Message. Its recipient,
18
+ * continuation, claim and delivery receipts are never rewritten or requeued. */
19
+ export function recoveredMessageContinuation(store, message, current) {
20
+ const target = message.continuation?.runId;
21
+ if (target === undefined || message.id === undefined || message.taskId !== current.taskId
22
+ || message.recipient?.roleName !== current.roleName
23
+ || message.recipient.workItemId !== current.workItemId || message.recipient.reviewRoundId !== current.reviewRoundId)
24
+ return false;
25
+ const binding = store.getTaskRoleSessionSet(current.taskId, current.roleName)?.providerBinding;
26
+ if (binding == null || !verifiedRecoveryChain(store, binding, current, true))
27
+ return false;
28
+ const events = store.listEventsByType(current.taskId, ["run.retried"]);
29
+ const visited = new Set();
30
+ let id = current.id;
31
+ while (id !== target) {
32
+ if (visited.has(id))
33
+ return false;
34
+ visited.add(id);
35
+ const edge = events.find(e => e.payload.runId === id);
36
+ if (edge?.payload.previousRunId === undefined)
37
+ return false;
38
+ id = edge.payload.previousRunId;
39
+ }
40
+ const source = store.getRun(current.taskId, target);
41
+ if (source?.inputs[0]?.input.source.channel !== "message-continuation")
42
+ return false;
43
+ return readRunContextSnapshot(store, current).resources.some(entry => {
44
+ if (entry.ref.store !== "task-message" || entry.ref.refId !== message.id)
45
+ return false;
46
+ const frozen = entry.value;
47
+ return frozen.body === message.body && frozen.createdAt === message.createdAt
48
+ && isDeepStrictEqual(frozen.recipient, message.recipient);
49
+ });
50
+ }
51
+ function verifiedRecoveryChain(store, binding, current, admitted) {
52
+ const retry = binding.retry;
53
+ const turn = binding.run;
54
+ const conversation = binding.conversations.find(c => c.epoch === binding.currentConversationEpoch);
55
+ if (binding.providerNamespace !== "openai/codex"
56
+ || retry === undefined || retry.status === "recovered" || retry.failedNativeTurnId === undefined
57
+ || binding.authority.owner !== "controller" || retry.authorityEpoch !== binding.authority.epoch
58
+ || conversation?.conversationId !== retry.nativeSessionId
59
+ || turn?.runId !== current.id
60
+ || turn.authorityEpoch !== binding.authority.epoch || turn.retrySupported !== true
61
+ || turn.input?.kind !== "run" || turn.input.taskId !== current.taskId
62
+ || current.purpose !== "execution")
63
+ return false;
64
+ if (admitted
65
+ ? current.status !== "active" || turn.status !== "accepted"
66
+ || retry.currentAttemptId !== turn.attemptId || retry.successorRunId !== current.id
67
+ || retry.successorAutomatic !== false
68
+ : current.status !== "failed" || turn.status !== "rejected"
69
+ || turn.nativeTurnId !== undefined || turn.input.runId !== current.id)
70
+ return false;
71
+ const runs = store.listRuns(current.taskId).filter(r => r.roleName === current.roleName);
72
+ const start = runs.findIndex(r => r.result?.provider?.attemptId === retry.failureAttemptId
73
+ && r.result.provider.nativeTurnId === retry.failedNativeTurnId
74
+ && r.result.provider.conversationId === retry.nativeSessionId
75
+ && r.result.provider.status === "failed" && r.status === "failed");
76
+ const end = runs.findIndex(r => r.id === current.id);
77
+ if (start < 0 || end <= start || end !== runs.length - 1)
78
+ return false;
79
+ if (runs.slice(start, end + 1).some(run => run.purpose !== current.purpose
80
+ || run.workItemId !== current.workItemId || run.reviewRoundId !== current.reviewRoundId
81
+ || run.executionGroupId !== current.executionGroupId || run.executionLaneId !== current.executionLaneId
82
+ || run.sourceExecutionGroupId !== current.sourceExecutionGroupId
83
+ || !isDeepStrictEqual(run.effective, current.effective)
84
+ || !isDeepStrictEqual(run.workspace, current.workspace)))
85
+ return false;
86
+ const events = store.listEventsByType(current.taskId, ["runtime.agent-error", "runtime.observation", "run.retried", "message.continuation"]);
87
+ const anchor = events.findIndex(event => {
88
+ const observation = runtimeObservationFromTaskEvent(event);
89
+ return observation?.kind === "turn.failed"
90
+ && observation.fence.roleName === current.roleName
91
+ && observation.fence.runId === runs[start].id
92
+ && observation.fence.nativeSessionId === retry.nativeSessionId
93
+ && observation.fence.receiptId === retry.failureAttemptId
94
+ && observation.fence.nativeTurnId === retry.failedNativeTurnId
95
+ && observation.payload.failure?.error.inputDisposition === "accepted"
96
+ && observation.payload.failure.error.sessionDisposition === "recoverable";
97
+ });
98
+ if (anchor < 0)
99
+ return false;
100
+ // Reject any intervening accepted/unknown input, including an unowned native
101
+ // Turn. Timestamps or absence of a Run result are not non-submission proof.
102
+ for (const event of events.slice(anchor + 1)) {
103
+ if (event.payload.roleName !== current.roleName
104
+ || event.payload.nativeSessionId !== retry.nativeSessionId)
105
+ continue;
106
+ if (event.type === "runtime.agent-error" && event.payload.inputDisposition !== "not-accepted"
107
+ && !(event.payload.attemptId === retry.failureAttemptId
108
+ && event.payload.nativeTurnId === retry.failedNativeTurnId
109
+ && event.payload.phase === "turn-execute" && event.payload.inputDisposition === "accepted"))
110
+ return false;
111
+ if (event.type !== "runtime.observation")
112
+ continue;
113
+ const observation = runtimeObservationFromTaskEvent(event);
114
+ if (admitted && observation?.fence.receiptId === turn.attemptId
115
+ && observation.fence.runId === current.id && observation.fence.nativeTurnId === turn.nativeTurnId
116
+ && observation.kind !== "input.delivery-unknown")
117
+ continue;
118
+ if (observation === null || observation.kind === "input.accepted"
119
+ || observation.kind === "input.delivery-unknown"
120
+ || observation.kind.startsWith("turn.")
121
+ && observation.fence.nativeTurnId !== retry.failedNativeTurnId)
122
+ return false;
123
+ }
124
+ for (let i = start + 1; i <= end; i++) {
125
+ const prior = runs[i - 1];
126
+ const run = runs[i];
127
+ if (!(admitted && i === end)) {
128
+ if (run.status !== "failed" || run.result?.provider !== undefined
129
+ || run.result?.failureReason !== "runtime-failed")
130
+ return false;
131
+ const errors = events.filter(e => e.type === "runtime.agent-error" && e.payload.runId === run.id);
132
+ const proof = errors.find(({ payload: p }) => p.roleName === current.roleName
133
+ && p.agentId === run.effective.agentId && p.nativeSessionId === retry.nativeSessionId
134
+ && p.source === "host" && p.adapterId === "codex" && p.driverId === "openai/codex"
135
+ && p.phase === "turn-submit" && p.inputDisposition === "not-accepted"
136
+ && p.errorName === "CodexPreSubmissionError" && p.registrationDisposition === "committed"
137
+ && p.attemptId !== undefined);
138
+ if (proof === undefined || errors.some(e => e.payload.inputDisposition !== "not-accepted")
139
+ || i === end && proof.payload.attemptId !== turn.attemptId)
140
+ return false;
141
+ }
142
+ if (events.some(e => e.type === "run.retried"
143
+ && e.payload.runId === run.id && e.payload.previousRunId === prior.id))
144
+ continue;
145
+ if (run.inputs[0]?.input.source.channel !== "message-continuation"
146
+ || !events.some(e => e.type === "message.continuation" && e.payload.runId === run.id))
147
+ return false;
148
+ const snapshot = readRunContextSnapshot(store, run);
149
+ if (!snapshot.resources.some(r => r.ref.store === "source-run" && r.ref.refId === prior.id))
150
+ return false;
151
+ }
152
+ return true;
153
+ }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
6
6
  import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "./controllerProcessIdentity.js";
7
7
  import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
8
+ import { monotonicMilliseconds } from "./controllerTelemetry.js";
8
9
  import { CONTROLLER_DISCOVERY_PATH, ControllerProtocolError, encodeControllerRequest, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
9
10
  export class ControllerClientError extends Error {
10
11
  code;
@@ -169,6 +170,10 @@ export async function stopOrphanedFileTaskController(home, timeoutMs, options =
169
170
  }
170
171
  function exchange(socketPath, requestLine, expectedId, timeoutMs, method) {
171
172
  return new Promise((resolve, reject) => {
173
+ const startedAtMs = monotonicMilliseconds();
174
+ let connectedAtMs = null;
175
+ let writeCompletedAtMs = null;
176
+ let responseAtMs = null;
172
177
  const socket = createConnection(socketPath);
173
178
  let buffer = Buffer.alloc(0);
174
179
  let settled = false;
@@ -194,19 +199,28 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs, method) {
194
199
  delivery: error.diagnostic?.delivery ?? (deliveryStarted ? "unconfirmed" : "not-sent"),
195
200
  method,
196
201
  requestId: expectedId,
197
- socketPath
202
+ socketPath,
203
+ timing: { startedAtMs, connectedAtMs, writeCompletedAtMs, responseAtMs,
204
+ endedAtMs: monotonicMilliseconds() }
198
205
  }));
199
206
  };
200
207
  socket.on("connect", () => {
208
+ if (settled)
209
+ return;
210
+ connectedAtMs = monotonicMilliseconds();
201
211
  // Once write begins, a missing response cannot prove that the Controller
202
212
  // did not commit the request. Callers must use their domain identity to
203
213
  // decide whether an explicit retry is safe.
204
214
  deliveryStarted = true;
205
- socket.write(requestLine);
215
+ socket.write(requestLine, error => {
216
+ if (error == null)
217
+ writeCompletedAtMs = monotonicMilliseconds();
218
+ });
206
219
  });
207
220
  socket.on("data", (chunk) => {
208
221
  if (settled)
209
222
  return;
223
+ responseAtMs ??= monotonicMilliseconds();
210
224
  buffer = Buffer.concat([buffer, chunk]);
211
225
  const newline = buffer.indexOf(0x0a);
212
226
  if (newline < 0) {
@@ -17,7 +17,7 @@ import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
17
17
  import { CommandExecutionError } from "../tmux/commandExecutor.js";
18
18
  import { redactAgentErrorText } from "../runtime/agentError.js";
19
19
  import { detectRunningRelease, readActiveReleasePointer, readHandoverFence, verifyReleaseIntegrity, writeHandoverFence, writeRuntimeIdentity } from "../release/runtimeRelease.js";
20
- import { isBuiltinControllerMethod, ControllerCommandObserver, ControllerEventLoopDelay } from "./controllerTelemetry.js";
20
+ import { isBuiltinControllerMethod, ControllerCommandObserver, ControllerEventLoopDelay, monotonicMilliseconds } from "./controllerTelemetry.js";
21
21
  import { parseLinuxProcessStartIdentity, removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
22
22
  /** Methods that stay available while a handover fence is active. */
23
23
  const HANDOVER_ALLOWED_METHODS = new Set([
@@ -54,7 +54,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
54
54
  await chmod(socketDirectory, 0o700);
55
55
  const token = randomBytes(32).toString("hex");
56
56
  let closeRunning = async () => undefined;
57
- const commandObserver = new ControllerCommandObserver();
57
+ const commandObserver = new ControllerCommandObserver(undefined, options.onRequestDiagnostic);
58
58
  const eventLoopDelay = new ControllerEventLoopDelay();
59
59
  const telemetry = Object.freeze({
60
60
  commandObserver,
@@ -245,6 +245,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
245
245
  }
246
246
  }
247
247
  function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
248
+ const acceptedAtMs = monotonicMilliseconds();
248
249
  let buffer = Buffer.alloc(0);
249
250
  let complete = false;
250
251
  const fail = (code, message, id = "invalid") => {
@@ -273,7 +274,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
273
274
  return;
274
275
  }
275
276
  complete = true;
276
- void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity);
277
+ void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity, { acceptedAtMs, receivedAtMs: monotonicMilliseconds() });
277
278
  });
278
279
  socket.on("end", () => {
279
280
  if (!complete)
@@ -281,7 +282,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
281
282
  });
282
283
  socket.on("error", () => undefined);
283
284
  }
284
- async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
285
+ async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity, socketTiming) {
285
286
  let request;
286
287
  try {
287
288
  request = parseControllerRequest(line);
@@ -311,9 +312,17 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
311
312
  // observation completes when the response is handed to the socket, so a
312
313
  // later status snapshot sees prior built-in completions and the dispatcher
313
314
  // service-time metric stays a distinct observation.
314
- const observation = telemetry.commandObserver.start(request.method, request.id);
315
+ const observation = telemetry.commandObserver.start(request.method, request.id, socketTiming);
316
+ // A queued response is not a local flush, and even a local flush is not a
317
+ // client receipt. Close before finish retains a failed transport observation.
318
+ socket.once("close", () => observation.flushed(true));
319
+ if (socket.destroyed)
320
+ observation.flushed(true);
315
321
  const send = (response, onFlushed) => {
316
322
  observation.complete(isBuiltinControllerMethod(request.method) ? "builtin" : "dispatched", response.ok ? "success" : "failure");
323
+ // Install only for our response: a peer FIN can auto-end an otherwise
324
+ // empty socket while its asynchronous handler is still running.
325
+ socket.once("finish", () => observation.flushed());
317
326
  sendResponse(socket, response, onFlushed);
318
327
  };
319
328
  // Issue 02: a fenced Controller is mid-handover. It stops accepting new
@@ -13,10 +13,10 @@ export class ControllerWorkObserver {
13
13
  constructor(clock = monotonicMilliseconds) {
14
14
  this.clock = clock;
15
15
  }
16
- start(phase, target) {
16
+ start(phase, target, startedAtMs = this.clock()) {
17
17
  const token = {};
18
18
  const entry = { phase, target: target === undefined ? null
19
- : createHash("sha256").update(target).digest("hex").slice(0, 16), startedAt: this.clock() };
19
+ : createHash("sha256").update(target).digest("hex").slice(0, 16), startedAt: startedAtMs };
20
20
  this.#active.set(token, entry);
21
21
  return (failed = false) => {
22
22
  if (!this.#active.delete(token))
@@ -76,6 +76,7 @@ const BUILTIN_METHODS = new Set([
76
76
  "controller.handover-state"
77
77
  ]);
78
78
  const OBSERVED_METHODS = new Set([...BUILTIN_METHODS,
79
+ "runtime.host-observation-apply", "runtime.observation-apply",
79
80
  "scheduler.signal", "scheduler.scan", "scheduler.configure",
80
81
  "runtime.ensure-role-session", "runtime.stop-role-session",
81
82
  "capability.search", "capability.describe", "capability.call",
@@ -104,6 +105,7 @@ export const productionTelemetryScheduler = {
104
105
  * for end-to-end latency and is never double-counted.
105
106
  */
106
107
  export class ControllerCommandObserver {
108
+ onRequestDiagnostic;
107
109
  #clock;
108
110
  #received = 0;
109
111
  #completed = 0;
@@ -116,22 +118,64 @@ export class ControllerCommandObserver {
116
118
  #dispatchedCompleted = 0;
117
119
  #dispatchedFailed = 0;
118
120
  #observations;
119
- constructor(clock = monotonicMilliseconds) {
121
+ #transport;
122
+ constructor(clock = monotonicMilliseconds, onRequestDiagnostic) {
123
+ this.onRequestDiagnostic = onRequestDiagnostic;
120
124
  this.#clock = clock;
121
125
  this.#observations = new ControllerWorkObserver(clock);
126
+ this.#transport = new ControllerWorkObserver(clock);
122
127
  }
123
- start(method = "unknown", requestId) {
128
+ start(method = "unknown", requestId, socketTiming) {
124
129
  this.#received += 1;
125
130
  this.#inFlight += 1;
126
131
  const token = {};
127
132
  this.#inFlightStarted.set(token, this.#clock());
128
133
  const finish = this.#observations.start(OBSERVED_METHODS.has(method) ? method : "other", requestId);
134
+ // Attach socket timestamps only after authentication, using the same hash
135
+ // as route accounting. No raw frames, params or tokens enter observations.
136
+ if (socketTiming !== undefined) {
137
+ this.#transport.start("socket.accept-to-route", requestId, socketTiming.acceptedAtMs)();
138
+ this.#transport.start("socket.receive-to-route", requestId, socketTiming.receivedAtMs)();
139
+ }
140
+ const finishFlush = socketTiming === undefined ? () => undefined
141
+ : this.#transport.start("socket.route-to-flush", requestId);
142
+ const routedAtMs = this.#clock();
143
+ let handlerCompletedAtMs, flushCompletedAtMs;
144
+ let handlerFailed = false, flushFailed = false;
145
+ const emit = () => {
146
+ if (socketTiming === undefined || handlerCompletedAtMs === undefined
147
+ || flushCompletedAtMs === undefined)
148
+ return;
149
+ const elapsedMs = Math.max(0, Math.max(handlerCompletedAtMs, flushCompletedAtMs) - socketTiming.acceptedAtMs);
150
+ if (!handlerFailed && !flushFailed && elapsedMs < 1000)
151
+ return;
152
+ try {
153
+ this.onRequestDiagnostic?.({
154
+ target: requestId === undefined ? null
155
+ : createHash("sha256").update(requestId).digest("hex").slice(0, 16),
156
+ phase: OBSERVED_METHODS.has(method) ? method : "other",
157
+ ...socketTiming, routedAtMs, handlerCompletedAtMs, flushCompletedAtMs,
158
+ failed: handlerFailed || flushFailed, handlerFailed, flushFailed, elapsedMs
159
+ });
160
+ }
161
+ catch { /* Diagnostic sinks cannot change command effects. */ }
162
+ };
129
163
  let completed = false;
130
164
  return {
165
+ flushed: (failed = false) => {
166
+ if (flushCompletedAtMs !== undefined)
167
+ return;
168
+ flushCompletedAtMs = this.#clock();
169
+ flushFailed = failed;
170
+ finishFlush(failed);
171
+ emit();
172
+ },
131
173
  complete: (kind, outcome) => {
132
174
  if (completed)
133
175
  return;
134
176
  completed = true;
177
+ handlerCompletedAtMs = this.#clock();
178
+ handlerFailed = outcome === "failure";
135
179
  this.#inFlight = Math.max(0, this.#inFlight - 1);
136
180
  this.#inFlightStarted.delete(token);
137
181
  finish(outcome === "failure");
@@ -150,6 +194,7 @@ export class ControllerCommandObserver {
150
194
  else {
151
195
  this.#dispatchedCompleted += 1;
152
196
  }
197
+ emit();
153
198
  }
154
199
  };
155
200
  }
@@ -171,7 +216,8 @@ export class ControllerCommandObserver {
171
216
  completed: this.#dispatchedCompleted,
172
217
  failed: this.#dispatchedFailed
173
218
  },
174
- observations: this.#observations.snapshot()
219
+ observations: this.#observations.snapshot(),
220
+ transport: this.#transport.snapshot()
175
221
  };
176
222
  }
177
223
  }
@@ -79,8 +79,17 @@ export function describeCliFailure(error, context = {}) {
79
79
  ? "The Controller returned an error response; it does not prove rollback of earlier effects. " + RECONCILE
80
80
  : RECONCILE;
81
81
  const help = error instanceof CliError && error.helpText !== undefined ? clean(error.helpText.trimEnd()) : undefined;
82
- const message = [
83
- ...chain.map((item, index) => `${index === 0 ? "" : "Caused by: "}${item.code === undefined ? "" : `[${item.code}] `}${item.message}`),
82
+ const causes = chain.map((item, index) => `${index === 0 ? "" : "Caused by: "}${item.code === undefined ? "" : `[${item.code}] `}${item.message}`);
83
+ const lines = (parts) => parts.filter((part) => part !== undefined && part.length > 0).join("\n");
84
+ const jsonMessage = lines([
85
+ causes[0],
86
+ effects,
87
+ guidance,
88
+ "Use the same Home and CLI in details.diagnostic; its causes retain the full error chain. Recovery commands are not run automatically.",
89
+ help
90
+ ]);
91
+ const message = lines([
92
+ ...causes,
84
93
  diagnostic.operation === undefined ? undefined : `Operation: ${diagnostic.operation}`,
85
94
  diagnostic.home === undefined ? undefined : `YUI_HOME: ${diagnostic.home}`,
86
95
  diagnostic.cli === undefined ? undefined : `CLI entry: ${diagnostic.cli}`,
@@ -92,9 +101,9 @@ export function describeCliFailure(error, context = {}) {
92
101
  guidance,
93
102
  "Recovery commands shown as `yui` must use the same Home and matching CLI entry above; they are not run automatically.",
94
103
  help
95
- ].filter((part) => part !== undefined && part.length > 0).join("\n");
104
+ ]);
96
105
  return {
97
- code, exitCode, message,
106
+ code, exitCode, message, jsonMessage,
98
107
  details: {
99
108
  ...(error instanceof CliError ? sanitizeFailureDetails(error.details, context) : {}),
100
109
  diagnostic
@@ -103,7 +112,7 @@ export function describeCliFailure(error, context = {}) {
103
112
  }
104
113
  export function renderCliFailure(failure, json, jsonCode = failure.code) {
105
114
  return json
106
- ? JSON.stringify({ ok: false, code: jsonCode, message: failure.message, details: failure.details })
115
+ ? JSON.stringify({ ok: false, code: jsonCode, message: failure.jsonMessage, details: failure.details })
107
116
  : `${failure.code}: ${failure.message}`;
108
117
  }
109
118
  function errorCode(error) {