@zq-silk/yui 2.21.0 → 2.23.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 (145) hide show
  1. package/README.md +5 -0
  2. package/dist/cli/commandCatalog.js +87 -27
  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/cli/updateCommand.js +20 -3
  9. package/dist/cli/updateOrchestrator.js +13 -0
  10. package/dist/cli/updatePorts.js +27 -0
  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 +217 -64
  18. package/dist/commands/taskContextCommand.js +26 -4
  19. package/dist/commands/taskFactCommands.js +15 -2
  20. package/dist/commands/taskIntegrationCommands.js +3 -3
  21. package/dist/commands/taskRoleRuntimeStatus.js +19 -5
  22. package/dist/commands/textInput.js +2 -2
  23. package/dist/context/contextFamilies.js +10 -0
  24. package/dist/context/currentSkills.js +37 -4
  25. package/dist/context/runContextPack.js +44 -5
  26. package/dist/context/runInputContract.js +1 -1
  27. package/dist/context/sessionBootstrapManifest.js +21 -6
  28. package/dist/context/sessionSkillNotice.js +74 -0
  29. package/dist/context/taskContext.js +100 -16
  30. package/dist/controlPlaneCli.js +17 -7
  31. package/dist/controller/agentRuntimeObserver.js +57 -12
  32. package/dist/controller/clientRuntime.js +66 -18
  33. package/dist/controller/controller.js +19 -2
  34. package/dist/controller/diagnosticJournal.js +10 -2
  35. package/dist/controller/fileSchedulerStoreAdapter.js +74 -24
  36. package/dist/controller/hostLifecycle.js +153 -0
  37. package/dist/controller/hostLifecycleChecks.js +27 -0
  38. package/dist/controller/hostUpgrade.js +344 -0
  39. package/dist/controller/messageHealth.js +4 -3
  40. package/dist/controller/providerRetryAdmission.js +15 -1
  41. package/dist/controller/providerRetryDelivery.js +18 -2
  42. package/dist/controller/runtime.js +82 -21
  43. package/dist/controller/runtimeEventInbox.js +107 -13
  44. package/dist/controller/runtimeEventProcessor.js +3 -3
  45. package/dist/controller/sessionNotify.js +5 -46
  46. package/dist/controller/taskRunRecovery.js +153 -0
  47. package/dist/core/controllerClient.js +16 -2
  48. package/dist/core/controllerServer.js +14 -5
  49. package/dist/core/controllerTelemetry.js +51 -5
  50. package/dist/errors/cliFailure.js +14 -5
  51. package/dist/execution/codexThreadNaming.js +54 -43
  52. package/dist/executor/agentAdapter.js +32 -4
  53. package/dist/executor/agentConfigurationFields.js +2 -0
  54. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  55. package/dist/integration/gitIntegrationService.js +3 -2
  56. package/dist/integration/integrationSourceApplication.js +12 -1
  57. package/dist/interaction/notificationPresentation.js +11 -16
  58. package/dist/interaction/operatorPresentation.js +1 -1
  59. package/dist/message/messageContinuation.js +3 -1
  60. package/dist/output/boundedRead.js +36 -7
  61. package/dist/output/rolePresentation.js +3 -1
  62. package/dist/runtime/agentEndpoint.js +11 -1
  63. package/dist/runtime/agentEndpointIdentity.js +2 -0
  64. package/dist/runtime/agentHost.js +174 -40
  65. package/dist/runtime/agentHostImplementation.js +3 -1
  66. package/dist/runtime/codexAppServerRuntime.js +113 -24
  67. package/dist/runtime/codexInteractiveHost.js +102 -11
  68. package/dist/runtime/codexNativeAccess.js +1 -1
  69. package/dist/runtime/contextRecovery.js +15 -0
  70. package/dist/runtime/hostSessionLifecycle.js +83 -0
  71. package/dist/runtime/hostUpgradeGate.js +43 -0
  72. package/dist/runtime/lifecycleChecks.js +47 -0
  73. package/dist/runtime/lifecycleReservation.js +9 -1
  74. package/dist/runtime/nativeTitleReceipt.js +31 -0
  75. package/dist/runtime/nativeTitleSync.js +192 -0
  76. package/dist/runtime/providerRetry.js +9 -6
  77. package/dist/runtime/providerRuntimeIdentity.js +1 -1
  78. package/dist/runtime/structuredProviderHost.js +65 -35
  79. package/dist/scheduler/wakeupQueue.js +10 -4
  80. package/dist/storage/sqliteSchema.js +29 -0
  81. package/dist/storage/storageVersions.js +1 -1
  82. package/dist/tmux/tmuxManager.js +4 -2
  83. package/dist/web/webSettings.js +2 -0
  84. package/docs/authorization.md +18 -3
  85. package/docs/cli-information-contract.md +228 -6
  86. package/docs/cli-information-contract.zh-CN.md +95 -5
  87. package/docs/managed-turn-and-session-runtime.md +177 -21
  88. package/docs/managed-turn-and-session-runtime.zh-CN.md +126 -14
  89. package/docs/provider-retry.md +11 -0
  90. package/docs/release-workflow.md +65 -0
  91. package/docs/release-workflow.zh-CN.md +48 -0
  92. package/docs/roles-and-configuration.md +28 -0
  93. package/docs/roles-and-configuration.zh-CN.md +21 -0
  94. package/docs/storage-baseline.md +13 -1
  95. package/docs/storage-baseline.zh-CN.md +5 -1
  96. package/docs/task-delivery.md +7 -0
  97. package/docs/task-delivery.zh-CN.md +5 -0
  98. package/docs/testing/review-evaluation.md +7 -7
  99. package/docs/testing/review-evaluation.zh-CN.md +7 -7
  100. package/docs/testing/verification-levels.md +45 -2
  101. package/docs/testing/verification-levels.zh-CN.md +21 -2
  102. package/i18n/README.zh-CN.md +6 -0
  103. package/package.json +1 -1
  104. package/skills/yui-leader/SKILL.md +98 -193
  105. package/skills/yui-leader/VERSION +1 -1
  106. package/skills/yui-leader/references/artifacts.md +41 -0
  107. package/skills/yui-leader/references/assignment-execution.md +140 -0
  108. package/skills/yui-leader/references/completion.md +79 -0
  109. package/skills/yui-leader/references/delivery-scope.md +26 -0
  110. package/skills/yui-leader/references/execution.md +16 -487
  111. package/skills/yui-leader/references/implementation.md +81 -0
  112. package/skills/yui-leader/references/planning.md +7 -45
  113. package/skills/yui-leader/references/review.md +90 -0
  114. package/skills/yui-leader/references/task-progress.md +23 -0
  115. package/skills/yui-leader/references/work-allocation.md +118 -0
  116. package/skills/yui-operator/SKILL.md +77 -333
  117. package/skills/yui-operator/VERSION +1 -1
  118. package/skills/yui-operator/references/body-transport.md +62 -0
  119. package/skills/yui-operator/references/configuration.md +47 -0
  120. package/skills/yui-operator/references/lifecycle.md +41 -0
  121. package/skills/yui-operator/references/progress.md +39 -0
  122. package/skills/yui-operator/references/routing.md +57 -0
  123. package/skills/yui-operator/references/submission.md +56 -0
  124. package/skills/yui-operator/references/task-delivery.md +1 -1
  125. package/skills/yui-operator/references/web-lifecycle.md +19 -0
  126. package/skills/yui-operator/scripts/verify-submission.mjs +37 -0
  127. package/skills/yui-runtime/SKILL.md +182 -531
  128. package/skills/yui-runtime/VERSION +1 -1
  129. package/skills/yui-runtime/references/archive.md +51 -0
  130. package/skills/yui-runtime/references/command-discovery.md +41 -0
  131. package/skills/yui-runtime/references/completed-followup.md +65 -0
  132. package/skills/yui-runtime/references/context-ordering.md +3 -34
  133. package/skills/yui-runtime/references/context-reads.md +61 -0
  134. package/skills/yui-runtime/references/dispatched-run.md +61 -0
  135. package/skills/yui-runtime/references/execution-failure.md +105 -0
  136. package/skills/yui-runtime/references/global-context.md +18 -0
  137. package/skills/yui-runtime/references/global-input-controls.md +44 -0
  138. package/skills/yui-runtime/references/host-custody.md +30 -0
  139. package/skills/yui-runtime/references/inbox-recovery.md +77 -0
  140. package/skills/yui-runtime/references/incremental-context.md +40 -0
  141. package/skills/yui-runtime/references/publication.md +4 -111
  142. package/skills/yui-runtime/references/recovery.md +32 -192
  143. package/skills/yui-runtime/references/session-identity.md +18 -0
  144. package/skills/yui-runtime/references/session-replacement.md +45 -0
  145. 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);
@@ -2,10 +2,9 @@ import { callController } from "../core/controllerClient.js";
2
2
  import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
3
3
  import { transportAgentResult } from "../domain/agentResultTransport.js";
4
4
  import { runtimeLifecycleSignalKey } from "../runtime/lifecycleReservation.js";
5
- import { setCodexThreadName } from "../execution/codexThreadNaming.js";
6
5
  import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
7
6
  /** Hidden CLI entrypoint used by Codex's structured notify hook. */
8
- export async function runSessionNotifyCommand(payloadArgument, environment = process.env, call, setThreadName = setCodexThreadName) {
7
+ export async function runSessionNotifyCommand(payloadArgument, environment = process.env, call) {
9
8
  const params = parseCodexSessionNotification(payloadArgument, environment);
10
9
  // Old global TUIs can still emit their invocation-local hook. It is not
11
10
  // evidence of a shared-daemon Thread's identity or process lifecycle.
@@ -15,7 +14,7 @@ export async function runSessionNotifyCommand(payloadArgument, environment = pro
15
14
  const home = requireText(environment.YUI_HOME, "YUI_HOME");
16
15
  // The caller reports native identity; durable state supplies the current AgentRun.
17
16
  const current = currentNotifyRun(home, params);
18
- const enqueued = new FileRuntimeEventInbox(home).enqueueRunTerminal({
17
+ new FileRuntimeEventInbox(home).enqueueRunTerminal({
19
18
  scope: params.scope,
20
19
  ...(params.scope === "task" ? { taskId: params.taskId } : {}),
21
20
  roleName: params.roleName,
@@ -42,11 +41,9 @@ export async function runSessionNotifyCommand(payloadArgument, environment = pro
42
41
  roleName: params.roleName
43
42
  })
44
43
  }, { timeoutMs: 100 }).catch(() => { });
45
- if (enqueued.created && shouldSetThreadName(home, params, current.runId)) {
46
- const request = threadNameRequest(params, environment);
47
- if (request !== null)
48
- await setThreadName(request).catch(() => { });
49
- }
44
+ // Title synchronization belongs to the Host's separately fenced metadata
45
+ // lane. A terminal notification is neither a rename receipt nor permission
46
+ // to replay an unknown write or overwrite a native user's name.
50
47
  }
51
48
  /**
52
49
  * Resolve the active AgentRun only when the notifying native Session matches.
@@ -130,44 +127,6 @@ function requireText(value, label) {
130
127
  throw new Error(`${label} is invalid.`);
131
128
  return text;
132
129
  }
133
- function shouldSetThreadName(home, params, runId) {
134
- if (params.scope !== "task"
135
- || params.title === undefined
136
- || runId === undefined)
137
- return false;
138
- try {
139
- const store = openCurrentTaskStore(home);
140
- return store.getRun(params.taskId, runId)?.mode === "new";
141
- }
142
- catch {
143
- return false;
144
- }
145
- }
146
- function threadNameRequest(params, environment) {
147
- if (params.title === undefined)
148
- return null;
149
- const command = environment.YUI_AGENT_COMMAND;
150
- const encodedBaseArgs = environment.YUI_AGENT_BASE_ARGS;
151
- if (command === undefined || encodedBaseArgs === undefined)
152
- return null;
153
- try {
154
- const parsed = JSON.parse(encodedBaseArgs);
155
- if (!Array.isArray(parsed)
156
- || parsed.some((value) => typeof value !== "string" || value.includes("\0"))) {
157
- return null;
158
- }
159
- return {
160
- command: requireText(command, "YUI_AGENT_COMMAND"),
161
- baseArgs: parsed,
162
- environment,
163
- threadId: params.nativeSessionId,
164
- name: params.title
165
- };
166
- }
167
- catch {
168
- return null;
169
- }
170
- }
171
130
  function isObject(value) {
172
131
  return typeof value === "object" && value !== null && !Array.isArray(value);
173
132
  }
@@ -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
  }