@zq-silk/yui 2.20.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 (142) hide show
  1. package/README.md +5 -0
  2. package/dist/cli/commandCatalog.js +88 -28
  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/invocationAuthority.js +2 -6
  7. package/dist/cli/invocationRouter.js +5 -5
  8. package/dist/cli/managedDiagnostics.js +24 -16
  9. package/dist/cli/roleWizard.js +5 -1
  10. package/dist/commands/durableJobCommands.js +2 -3
  11. package/dist/commands/globalRoleCommands.js +33 -8
  12. package/dist/commands/inboxCommands.js +39 -12
  13. package/dist/commands/operatorCommands.js +2 -5
  14. package/dist/commands/releaseCommands.js +16 -2
  15. package/dist/commands/roleConfiguration.js +25 -2
  16. package/dist/commands/sessionCommands.js +5 -2
  17. package/dist/commands/taskCommands.js +225 -99
  18. package/dist/commands/taskContextCommand.js +26 -4
  19. package/dist/commands/taskFactCommands.js +20 -7
  20. package/dist/commands/taskIntegrationCommands.js +3 -3
  21. package/dist/commands/taskRoleRuntimeStatus.js +15 -5
  22. package/dist/commands/taskUsageCommand.js +2 -6
  23. package/dist/commands/textInput.js +2 -2
  24. package/dist/context/contextFamilies.js +10 -0
  25. package/dist/context/currentSkills.js +6 -4
  26. package/dist/context/runContextPack.js +44 -5
  27. package/dist/context/runInputContract.js +1 -1
  28. package/dist/context/sessionBootstrapManifest.js +7 -5
  29. package/dist/context/sessionSkillNotice.js +53 -0
  30. package/dist/context/taskCatalog.js +4 -13
  31. package/dist/context/taskContext.js +135 -103
  32. package/dist/context/taskSearch.js +3 -7
  33. package/dist/controlPlaneCli.js +23 -25
  34. package/dist/controller/agentRuntimeObserver.js +57 -12
  35. package/dist/controller/clientRuntime.js +37 -8
  36. package/dist/controller/controller.js +3 -1
  37. package/dist/controller/diagnosticJournal.js +10 -2
  38. package/dist/controller/fileSchedulerStoreAdapter.js +72 -24
  39. package/dist/controller/jobClient.js +2 -2
  40. package/dist/controller/jobControl.js +2 -2
  41. package/dist/controller/jobDefinitionControl.js +5 -1
  42. package/dist/controller/messageHealth.js +4 -3
  43. package/dist/controller/providerRetryAdmission.js +15 -1
  44. package/dist/controller/providerRetryDelivery.js +18 -2
  45. package/dist/controller/runtime.js +15 -5
  46. package/dist/controller/runtimeEventInbox.js +107 -13
  47. package/dist/controller/runtimeEventProcessor.js +3 -3
  48. package/dist/controller/taskRunRecovery.js +153 -0
  49. package/dist/core/controllerClient.js +16 -2
  50. package/dist/core/controllerServer.js +14 -5
  51. package/dist/core/controllerTelemetry.js +51 -5
  52. package/dist/errors/cliFailure.js +14 -5
  53. package/dist/executor/agentAdapter.js +32 -4
  54. package/dist/executor/agentConfigurationFields.js +2 -0
  55. package/dist/executor/taskAgentCapabilities.js +2 -2
  56. package/dist/integration/gitIntegrationService.js +3 -2
  57. package/dist/integration/integrationSourceApplication.js +12 -1
  58. package/dist/interaction/notificationPresentation.js +11 -16
  59. package/dist/interaction/operatorPresentation.js +1 -1
  60. package/dist/message/messageContinuation.js +3 -1
  61. package/dist/output/boundedRead.js +36 -7
  62. package/dist/output/rolePresentation.js +3 -1
  63. package/dist/runtime/agentHost.js +12 -3
  64. package/dist/runtime/codexAppServerRuntime.js +110 -24
  65. package/dist/runtime/codexInteractiveHost.js +46 -0
  66. package/dist/runtime/providerRetry.js +5 -2
  67. package/dist/runtime/providerRuntimeIdentity.js +1 -1
  68. package/dist/runtime/structuredProviderHost.js +32 -34
  69. package/dist/scheduler/wakeupQueue.js +10 -4
  70. package/dist/storage/sqliteSchema.js +9 -0
  71. package/dist/storage/storageVersions.js +1 -1
  72. package/dist/task/taskAuthority.js +7 -3
  73. package/dist/tmux/tmuxManager.js +4 -2
  74. package/dist/web/webSettings.js +2 -0
  75. package/docs/authorization.md +18 -3
  76. package/docs/cli-information-contract.md +245 -6
  77. package/docs/cli-information-contract.zh-CN.md +107 -5
  78. package/docs/managed-turn-and-session-runtime.md +82 -21
  79. package/docs/managed-turn-and-session-runtime.zh-CN.md +56 -13
  80. package/docs/provider-retry.md +11 -0
  81. package/docs/release-workflow.md +62 -0
  82. package/docs/release-workflow.zh-CN.md +46 -0
  83. package/docs/roles-and-configuration.md +28 -0
  84. package/docs/roles-and-configuration.zh-CN.md +21 -0
  85. package/docs/storage-baseline.md +4 -1
  86. package/docs/storage-baseline.zh-CN.md +1 -1
  87. package/docs/task-delivery.md +7 -0
  88. package/docs/task-delivery.zh-CN.md +5 -0
  89. package/docs/task-discovery.md +5 -5
  90. package/docs/task-discovery.zh-CN.md +4 -4
  91. package/docs/testing/review-evaluation.md +7 -7
  92. package/docs/testing/review-evaluation.zh-CN.md +7 -7
  93. package/docs/testing/verification-levels.md +45 -2
  94. package/docs/testing/verification-levels.zh-CN.md +21 -2
  95. package/i18n/README.zh-CN.md +6 -0
  96. package/package.json +1 -1
  97. package/skills/yui-leader/SKILL.md +98 -189
  98. package/skills/yui-leader/VERSION +1 -1
  99. package/skills/yui-leader/references/artifacts.md +41 -0
  100. package/skills/yui-leader/references/assignment-execution.md +140 -0
  101. package/skills/yui-leader/references/completion.md +79 -0
  102. package/skills/yui-leader/references/delivery-scope.md +26 -0
  103. package/skills/yui-leader/references/execution.md +16 -487
  104. package/skills/yui-leader/references/implementation.md +81 -0
  105. package/skills/yui-leader/references/planning.md +7 -45
  106. package/skills/yui-leader/references/review.md +90 -0
  107. package/skills/yui-leader/references/task-progress.md +23 -0
  108. package/skills/yui-leader/references/work-allocation.md +118 -0
  109. package/skills/yui-operator/SKILL.md +77 -331
  110. package/skills/yui-operator/VERSION +1 -1
  111. package/skills/yui-operator/references/body-transport.md +62 -0
  112. package/skills/yui-operator/references/configuration.md +47 -0
  113. package/skills/yui-operator/references/lifecycle.md +41 -0
  114. package/skills/yui-operator/references/progress.md +39 -0
  115. package/skills/yui-operator/references/routing.md +57 -0
  116. package/skills/yui-operator/references/submission.md +56 -0
  117. package/skills/yui-operator/references/task-delivery.md +5 -2
  118. package/skills/yui-operator/references/web-lifecycle.md +19 -0
  119. package/skills/yui-operator/scripts/verify-submission.mjs +37 -0
  120. package/skills/yui-reviewer/SKILL.md +4 -2
  121. package/skills/yui-reviewer/VERSION +1 -1
  122. package/skills/yui-runtime/SKILL.md +177 -505
  123. package/skills/yui-runtime/VERSION +1 -1
  124. package/skills/yui-runtime/references/archive.md +51 -0
  125. package/skills/yui-runtime/references/command-discovery.md +41 -0
  126. package/skills/yui-runtime/references/completed-followup.md +65 -0
  127. package/skills/yui-runtime/references/context-ordering.md +3 -34
  128. package/skills/yui-runtime/references/context-reads.md +61 -0
  129. package/skills/yui-runtime/references/dispatched-run.md +61 -0
  130. package/skills/yui-runtime/references/execution-failure.md +105 -0
  131. package/skills/yui-runtime/references/global-context.md +18 -0
  132. package/skills/yui-runtime/references/global-input-controls.md +44 -0
  133. package/skills/yui-runtime/references/host-custody.md +30 -0
  134. package/skills/yui-runtime/references/inbox-recovery.md +77 -0
  135. package/skills/yui-runtime/references/incremental-context.md +40 -0
  136. package/skills/yui-runtime/references/publication.md +4 -104
  137. package/skills/yui-runtime/references/recovery.md +32 -192
  138. package/skills/yui-runtime/references/session-identity.md +18 -0
  139. package/skills/yui-runtime/references/session-replacement.md +45 -0
  140. package/skills/yui-runtime/references/uncertain-execution.md +29 -0
  141. package/skills/yui-worker/SKILL.md +7 -4
  142. package/skills/yui-worker/VERSION +1 -1
@@ -89,6 +89,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
89
89
  const running = await createFileTaskControllerRuntime(home, {
90
90
  ...options,
91
91
  diagnosticMetrics: () => diagnostics.metrics(),
92
+ onRequestDiagnostic: record => diagnostics.request(record),
92
93
  onError: error => { diagnostics.error(error); options.onError?.(error); }
93
94
  });
94
95
  let expectedAt = monotonicMilliseconds() + 250;
@@ -221,7 +222,7 @@ async function createFileTaskControllerRuntime(home, options) {
221
222
  && typeof session.nativeSessionId === "string" && session.nativeSessionId.trim().length > 0) {
222
223
  return session.nativeSessionId;
223
224
  }
224
- for (const event of runtimeEventInbox.list()) {
225
+ for (const event of await runtimeEventInbox.listAsync()) {
225
226
  if (event.type === "runtime-observation" && event.observation.kind === "session.started"
226
227
  && event.observation.fence.taskId === owner.taskId
227
228
  && event.observation.fence.roleName === owner.roleName
@@ -416,7 +417,7 @@ async function createFileTaskControllerRuntime(home, options) {
416
417
  store,
417
418
  environment: options.environment
418
419
  });
419
- const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, launchCoordinator, planner);
420
+ const lifecycleDispatcher = createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, options.dispatcher, launchCoordinator, planner, workerMaintenance ? asyncStoreClient : undefined);
420
421
  // Process-exit observations are persisted by the Agent Host before socket
421
422
  // delivery. Drain them while this Controller is still the only storage
422
423
  // writer and before it begins accepting new work after a handover.
@@ -607,6 +608,7 @@ async function createFileTaskControllerRuntime(home, options) {
607
608
  now: options.now,
608
609
  onError: options.onError,
609
610
  diagnosticMetrics: options.diagnosticMetrics,
611
+ onRequestDiagnostic: options.onRequestDiagnostic,
610
612
  lifecycleHost,
611
613
  providerRetry: createProviderRetryHooks(home, store, {
612
614
  now: options.now,
@@ -753,7 +755,7 @@ async function createFileTaskControllerRuntime(home, options) {
753
755
  throw error;
754
756
  }
755
757
  }
756
- export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, fallback, sharedLaunchCoordinator, environmentRefresher) {
758
+ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionHost, fallback, sharedLaunchCoordinator, environmentRefresher, observationWorker) {
757
759
  const launchCoordinator = sharedLaunchCoordinator
758
760
  ?? new RuntimeLaunchCoordinator(schedulerStore, sessionHost, {
759
761
  assertCurrent: (request) => {
@@ -774,12 +776,20 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
774
776
  if (event.type !== "runtime-observation" || event.host === undefined) {
775
777
  throw applicationError("INVALID_PARAMS", "Expected an Agent Host observation.");
776
778
  }
777
- return { outcome: schedulerStore.observeAgentHostObservation(event, new Date()) };
779
+ // Eager Host delivery and the normal Inbox drainer fold the same durable
780
+ // fact. Share their existing worker writer: a second synchronous writer
781
+ // here can wait on BEGIN IMMEDIATE and starve even status socket I/O.
782
+ // Await the original atomic fold; no eager ACK or cursor advancement.
783
+ return { outcome: observationWorker === undefined
784
+ ? schedulerStore.observeAgentHostObservation(event, new Date())
785
+ : await observationWorker.invokeObserver("observeAgentHostObservation", [event, new Date()]) };
778
786
  }
779
787
  if (method === "runtime.observation-apply") {
780
788
  try {
781
789
  return {
782
- outcome: schedulerStore.observeRuntimeObservation(createRuntimeObservation(params), new Date())
790
+ outcome: observationWorker === undefined
791
+ ? schedulerStore.observeRuntimeObservation(createRuntimeObservation(params), new Date())
792
+ : await observationWorker.invokeObserver("observeRuntimeObservation", [createRuntimeObservation(params), new Date()])
783
793
  };
784
794
  }
785
795
  catch (error) {
@@ -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
  }