@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -3,7 +3,7 @@ import { assertProjectActive, resolveProject } from "../repository/project.js";
3
3
  import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
4
4
  import { usageError } from "../errors/cliError.js";
5
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
6
- import { createIntegrationAttempt, recordResolutionDecision, supersedeIntegration, updateIntegrationAttempt } from "../integration/integrationAttempt.js";
6
+ import { createIntegrationAttempt, recordResolutionDecision, supersedeIntegration } from "../integration/integrationAttempt.js";
7
7
  import { GitIntegrationService } from "../integration/gitIntegrationService.js";
8
8
  import { FileTaskWorkspacePreparer } from "../repository/taskWorkspacePreparer.js";
9
9
  import { runTaskIntegrationQueueCommand } from "./taskIntegrationQueueCommands.js";
@@ -171,6 +171,7 @@ async function continueIntegration(args, store, home, now, options) {
171
171
  taskLocalActor(store, options.environment, integration.taskId);
172
172
  if (integration.status !== "validating"
173
173
  && integration.status !== "running"
174
+ && integration.status !== "conflicted"
174
175
  && (integration.status !== "blocked"
175
176
  || integration.resolution?.action !== "manual-resolution")) {
176
177
  throw usageError(`Integration is not ready to continue: ${integration.id}/${integration.status}.`);
@@ -181,11 +182,13 @@ async function runIntegration(store, home, integration, now, options) {
181
182
  const result = await new GitIntegrationService(home, store, undefined, now, options.environment, undefined, options.jobPort).integrate(integration.taskId, integration.id);
182
183
  const output = result.status === "committed"
183
184
  ? `Integrated ${integrationSourceLabel(result.attempt)} into ${result.attempt.targetRef} with CAS (${result.attempt.id})\n`
184
- : result.status === "blocked"
185
- ? `Integration ${result.attempt.id} requires a Task Agent resolution decision in ${result.workspace.path}\n`
186
- : result.status === "checks-running"
187
- ? `Integration ${result.attempt.id} checks are running as DurableJob ${result.job.id}; run 'yui task integration continue ${result.attempt.taskId}/${result.attempt.id}' when the job finishes\n`
188
- : `Integration ${result.attempt.id} failed; target ref was not advanced\n`;
185
+ : result.status === "conflicted"
186
+ ? `Integration ${result.attempt.id} is conflicted in ${result.workspace.path}; resolve files, then run 'yui task integration continue ${result.attempt.taskId}/${result.attempt.id}' (no prior resolve required)\n`
187
+ : result.status === "blocked"
188
+ ? `Integration ${result.attempt.id} needs Leader attention in ${result.workspace.path}: ${result.attempt.summary ?? result.attempt.conflict?.summary ?? "Inspect the current attempt"}\n`
189
+ : result.status === "checks-running"
190
+ ? `Integration ${result.attempt.id} checks are running as DurableJob ${result.job.id}; run 'yui task integration continue ${result.attempt.taskId}/${result.attempt.id}' when the job finishes\n`
191
+ : `Integration ${result.attempt.id} failed; inspect its target and preserved evidence\n`;
189
192
  return { output, data: result };
190
193
  }
191
194
  function resolveDecision(args, store, now, environment, home) {
@@ -223,41 +226,16 @@ async function abortIntegration(args, store, now, options, home) {
223
226
  const integration = requireIntegration(store, parsed.positionals[0], options.environment);
224
227
  requireActiveIntegrationTask(store, integration);
225
228
  taskLocalActor(store, options.environment, integration.taskId);
226
- if (integration.status !== "running" && integration.status !== "blocked") {
227
- throw usageError(`Integration cannot be aborted from ${integration.status}: ${integration.id}.`);
228
- }
229
229
  const reason = parsed.one.get("--reason");
230
230
  if (reason === undefined)
231
231
  throw usageError(usage);
232
- if (integration.jobId !== undefined && options.jobPort !== undefined) {
233
- await options.jobPort.cancelJob(integration.taskId, integration.jobId);
234
- }
235
- return store.transaction((tx) => {
236
- // Re-read inside the transaction: a concurrent `continue` can advance the
237
- // Attempt to validating (and then commit the target) after the initial
238
- // read. Aborting a validating or committed Attempt would leave the
239
- // target advanced while the Attempt is failed.
240
- const current = tx.getIntegrationAttempt(integration.taskId, integration.id);
241
- if (current === null) {
242
- throw usageError(`Integration Attempt not found: ${integration.taskId}/${integration.id}.`);
243
- }
244
- if (current.status !== "running" && current.status !== "blocked") {
245
- throw usageError(`Integration cannot be aborted from ${current.status}: ${current.id}.`);
246
- }
247
- taskLocalActor(tx, options.environment, current.taskId);
248
- const aborted = updateIntegrationAttempt(current, {
249
- status: "failed",
250
- checks: [
251
- ...(current.checks ?? []),
252
- { name: "aborted", outcome: "failed", details: reason }
253
- ]
254
- }, now);
255
- tx.saveIntegrationAttempt(aborted.taskId, aborted);
256
- return {
257
- output: `Aborted Integration ${aborted.id}; start a new Integration Attempt to retry\n`,
258
- data: { integration: aborted }
259
- };
260
- });
232
+ const settled = await new GitIntegrationService(home, store, undefined, () => now, options.environment, undefined, options.jobPort).abort(integration.taskId, integration.id, reason, (tx, taskId) => taskLocalActor(tx, options.environment, taskId));
233
+ return {
234
+ output: settled.status === "committed"
235
+ ? `Integration ${settled.id} had already advanced its target; recorded committed, not aborted.\n`
236
+ : `Aborted Integration ${settled.id}; candidate, workspace and history are preserved. This is not Git abort or proof of Job quiescence. Choose an authorized replacement delivery path.\n`,
237
+ data: { integration: settled }
238
+ };
261
239
  }
262
240
  function supersedeIntegrationCommand(args, store, now, environment, home) {
263
241
  const usage = "Task Integration supersede usage: yui task integration supersede <task>/<integration> --reason <text>.";
@@ -138,7 +138,7 @@ async function process(args, store, home, options) {
138
138
  if (result.status === "committed") {
139
139
  return `${entry.id} committed ${entry.changeSetId} -> ${entry.targetAfter ?? "-"}`;
140
140
  }
141
- if (result.status === "blocked") {
141
+ if (result.status === "blocked" || result.status === "conflicted") {
142
142
  return `${entry.id} conflicted ${entry.changeSetId}: ${entry.conflictSummary ?? "blocked"}`;
143
143
  }
144
144
  return `${entry.id} conflicted ${entry.changeSetId}: ${entry.conflictSummary ?? "gate failure"}`;
@@ -50,7 +50,7 @@ export function createTaskRemoteDeliveryProof(store, task, currentCandidate = nu
50
50
  delivery
51
51
  };
52
52
  }
53
- export function assertTaskRemoteDeliveryProof(store, task, proof, options = {}) {
53
+ export function assertTaskRemoteDeliveryProof(store, task, proof) {
54
54
  if (proof === undefined || proof.schemaVersion !== 1) {
55
55
  throw usageError(`Task remote-delivery preflight proof is required: ${task.id}.`);
56
56
  }
@@ -60,16 +60,16 @@ export function assertTaskRemoteDeliveryProof(store, task, proof, options = {})
60
60
  if (!isDeepStrictEqual(proof.publications, store.listPublicationReferences(task.id))) {
61
61
  throw usageError(`Task Publication evidence changed after remote-delivery preflight: ${task.id}.`);
62
62
  }
63
- assertTaskRemoteDeliveryIntegrated(proof.delivery, options);
63
+ assertTaskRemoteDeliveryIntegrated(proof.delivery);
64
64
  return proof.delivery;
65
65
  }
66
- export function assertTaskRemoteDeliveryIntegrated(delivery, options = {}) {
66
+ export function assertTaskRemoteDeliveryIntegrated(delivery) {
67
67
  if (!delivery.allMerged) {
68
68
  const headUnavailable = delivery.projects.filter((project) => (project.codeDelivery !== "none"
69
69
  && project.coverage === "head-unavailable"));
70
70
  if (headUnavailable.length > 0 && delivery.source === "task-completed") {
71
71
  throw usageError(`Task ${delivery.taskId} has no frozen completion heads for: ${headUnavailable.map(({ projectId }) => projectId).join(", ")}. Reopen and complete it again to record exact heads before archiving; `
72
- + "--force cannot override missing head evidence.");
72
+ + "An explicitly authorized --force archive retains this evidence gap.");
73
73
  }
74
74
  const uncovered = delivery.projects
75
75
  .filter((project) => project.codeDelivery !== "none" && !project.merged)
@@ -79,7 +79,7 @@ export function assertTaskRemoteDeliveryIntegrated(delivery, options = {}) {
79
79
  throw usageError(`Task ${delivery.taskId} is not fully merged into remote delivery: ${uncovered || "expected Project heads are unavailable"}. Record confirmed current-head evidence with task publication upsert, `
80
80
  + "or use task archive --abandon for an intentional non-merge.");
81
81
  }
82
- if (delivery.allVerified || options.forceUnverified === true)
82
+ if (delivery.allVerified)
83
83
  return;
84
84
  const unverified = delivery.projects
85
85
  .filter((project) => (project.codeDelivery !== "none"
@@ -91,7 +91,7 @@ export function assertTaskRemoteDeliveryIntegrated(delivery, options = {}) {
91
91
  throw usageError(`Task ${delivery.taskId} has merged Publication evidence that is not verified: `
92
92
  + `${unverified || "verification evidence is unavailable"}. Run task publication verify `
93
93
  + "for each Publication, or repeat task archive --integrated --force to explicitly "
94
- + "override verification. --force never overrides missing, stale, open, or closed merge evidence.");
94
+ + "archive despite evidence gaps, retaining the original delivery facts.");
95
95
  }
96
96
  export function renderTaskRemoteDelivery(delivery, indent = "") {
97
97
  const source = `${delivery.source}${delivery.provisional ? " (provisional)" : ""}`;
@@ -11,6 +11,40 @@ import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
11
11
  import { formatRunReceiptId } from "../task/taskRecordReference.js";
12
12
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
13
13
  import { projectSessionTokenMetrics, resolveSessionTokenIdentity } from "../runtime/sessionTokenMetrics.js";
14
+ export function taskRoleHostDiagnostic(host) {
15
+ const snapshot = host.snapshot;
16
+ if (snapshot === undefined)
17
+ return `unavailable: ${host.detail ?? "Host could not be read"}`;
18
+ const delivery = snapshot.eventDelivery;
19
+ return [
20
+ `Provider Host=${snapshot.state}`,
21
+ delivery === undefined ? "event delivery=unreported (legacy Host)" :
22
+ `pending facts=${delivery.pending}; native terminals awaiting acknowledgement=${delivery.pendingTerminals}`,
23
+ delivery?.failure === undefined ? undefined :
24
+ `reporting failure (${delivery.failure.stage}, ${delivery.failure.observedAt}): ${delivery.failure.detail}`,
25
+ snapshot.detail
26
+ ].filter(Boolean).join("; ");
27
+ }
28
+ /** Overlay live reporting evidence without rewriting Run/Provider/business truth. */
29
+ export function withTaskRoleHostObservation(status, host) {
30
+ if (host === undefined)
31
+ return status;
32
+ const snapshot = host.snapshot;
33
+ if (snapshot !== undefined && (snapshot.nativeSessionId !== status.nativeSession?.nativeSessionId
34
+ || snapshot.adapterId !== status.nativeSession?.adapterId)) {
35
+ return { ...status, host: { detail: "Host does not match the recorded native Session." } };
36
+ }
37
+ const reportingNeedsAttention = snapshot !== undefined && (snapshot.state === "failed" || snapshot.state === "exited"
38
+ || snapshot.eventDelivery?.failure !== undefined
39
+ || (snapshot.eventDelivery?.pendingTerminals ?? 0) > 0);
40
+ return {
41
+ ...status, host,
42
+ ...(reportingNeedsAttention ? {
43
+ health: "needs-attention",
44
+ healthReason: `${taskRoleHostDiagnostic(host)}. Formal Run status and business acceptance are unchanged.`
45
+ } : {})
46
+ };
47
+ }
14
48
  export function inspectTaskRoleRuntimeStatuses(taskId, roles, store, panes, now = new Date()) {
15
49
  const taskOpenInputRequestCount = store.listInputRequests(taskId)
16
50
  .filter((request) => request.status === "open").length;
@@ -99,6 +133,7 @@ export function renderTaskRoleRuntimeStatus(status) {
99
133
  : "none"}`,
100
134
  ` Native session ${nativeSession}`,
101
135
  ` AgentRuntime ${runtime}`,
136
+ ...(status.host === undefined ? [] : [` Host reporting ${taskRoleHostDiagnostic(status.host)}`]),
102
137
  ` Session tokens ${sessionTokens}`,
103
138
  ` Runtime cleanup ${status.runtimeCleanupPending ? "pending" : "none"}`,
104
139
  ` tmux pane ${tmux}`,
@@ -227,13 +227,22 @@ function authorizeContext(store, taskId, environment) {
227
227
  allow = new Set(pack.authority.readableRefs.map((ref) => `${ref.store}:${ref.refId}`));
228
228
  allow.add(`role:${caller.roleName}`);
229
229
  allow.add(`turn:${caller.currentRunId}`);
230
+ // A steer targets exactly one Role's current native Turn and is never shared
231
+ // Task intent (decision-3 §9). A Leader steer in particular stores as an
232
+ // untargeted `user` Message (no recipient/WorkItem/Run scope, since a Leader
233
+ // holds no Assignment), so recognize it explicitly: the shared block below
234
+ // must never leak it, and the steer-delta block authorizes it only to its own
235
+ // recipient. `interruptThen.reusedInput` is the interrupt-then composition
236
+ // whose carried input can itself be a steer.
237
+ const isSteerMessage = (message) => message.inputControl?.action === "steer"
238
+ || message.interruptThen?.reusedInput?.action === "steer";
230
239
  // A frozen Assignment is not a cutoff for the Task's current user intent.
231
240
  // Untargeted human/Operator messages are shared Task requirements; scoped
232
- // messages and other Roles' results still require the Assignment's refs.
241
+ // messages, other Roles' results, and steers still require an explicit grant.
233
242
  const sharedMessageIds = new Set(store.listMessages(taskId)
234
243
  .filter(message => (message.kind === "user" || message.kind === "operator")
235
244
  && message.recipient === undefined && message.workItemId === undefined
236
- && message.runId === undefined)
245
+ && message.runId === undefined && !isSteerMessage(message))
237
246
  .map(message => message.id));
238
247
  for (const id of sharedMessageIds)
239
248
  allow.add(`task-message:${id}`);
@@ -242,6 +251,33 @@ function authorizeContext(store, taskId, environment) {
242
251
  allow.add(`task-event:${event.id}`);
243
252
  }
244
253
  }
254
+ // A steer targets this Role's exact current native Turn (decision-3 §9,
255
+ // message-5 gap E). Unlike a queued or addressed Message — which reaches the
256
+ // Role through a new AgentRun's frozen Context pack — a steer is pushed into
257
+ // the *current* turn and is never captured by any frozen snapshot. So the
258
+ // steer header directs the recipient to reconcile the input "through your
259
+ // authorized Context read path"; that path must therefore resolve the exact
260
+ // steer Message it names. Authorize the steers addressed to this caller's
261
+ // exact current Assignment scope as a live delta — the same way untargeted
262
+ // user intent is layered on — WITHOUT expanding the frozen Assignment's
263
+ // readableRefs. Scope is the tightest signal a steer carries (it never gains
264
+ // a continuation runId), so a steer for a different WorkItem/Round is not
265
+ // authorized, and only genuine steers gain this read (an ordinary addressed
266
+ // Message still requires its own frozen delta).
267
+ const run = store.getRun(taskId, caller.currentRunId);
268
+ const steerMessageIds = new Set(store.listMessages(taskId)
269
+ .filter(message => isSteerMessage(message)
270
+ && message.recipient?.roleName === caller.roleName
271
+ && message.recipient.workItemId === run?.workItemId
272
+ && message.recipient.reviewRoundId === run?.reviewRoundId)
273
+ .map(message => message.id));
274
+ for (const id of steerMessageIds)
275
+ allow.add(`task-message:${id}`);
276
+ for (const event of store.listEvents(taskId)) {
277
+ if (event.type.startsWith("message.") && steerMessageIds.has(event.payload.messageId)) {
278
+ allow.add(`task-event:${event.id}`);
279
+ }
280
+ }
245
281
  }
246
282
  return { task, allow, caller };
247
283
  }
@@ -0,0 +1,155 @@
1
+ import { createTaskEvent } from "../event/taskEvent.js";
2
+ import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
3
+ import { createRuntimeObservation } from "../runtime/runtimeObservation.js";
4
+ import { readAgentHostObservationSource } from "../runtime/agentHostProtocol.js";
5
+ import { resolveRuntimeHookRunFence, RuntimeHookRunFenceError } from "./runtimeHookRunFence.js";
6
+ export class AgentHostObservationDeferred extends Error {
7
+ }
8
+ /** Resolve wire facts only here, using the Controller's current authoritative
9
+ * store. The producer's launch Run is never a substitute for input identity. */
10
+ export function resolveAgentHostObservation(store, event) {
11
+ const host = readAgentHostObservationSource(event.host);
12
+ const input = createRuntimeObservation(event.observation);
13
+ const { fence, kind } = input;
14
+ const hostKinds = [
15
+ "host.observed", "session.started", "session.ready", "session.ended", "session.failed",
16
+ "conversation.observed", "goal.updated", "goal.cleared",
17
+ "turn.accepted", "turn.completed", "turn.failed", "turn.cancelled",
18
+ "input.accepted", "input.rejected", "input.delivery-unknown",
19
+ "operation.started", "operation.completed", "operation.failed", "activity.observed"
20
+ ];
21
+ if (!hostKinds.includes(kind)
22
+ || (kind === "host.observed" ? input.authority !== "host"
23
+ : input.authority !== "provider-structured" && !(kind === "turn.accepted" && input.authority === "transport"))) {
24
+ throw new RuntimeHookRunFenceError("Agent Host fact exceeds its event authority.");
25
+ }
26
+ let driver;
27
+ try {
28
+ driver = builtinAgentDriverRegistry().requireByAdapterId(host.adapterId);
29
+ }
30
+ catch {
31
+ throw new RuntimeHookRunFenceError("Agent Host adapter is unsupported.");
32
+ }
33
+ if (event.taskId !== fence.taskId
34
+ || event.scope !== (fence.taskId === undefined ? "global" : "task") || fence.runId !== undefined
35
+ || fence.nativeSessionId === undefined
36
+ || driver.id !== fence.driverId
37
+ || (host.connection !== undefined && kind !== "host.observed")) {
38
+ throw new RuntimeHookRunFenceError("Agent Host fact source does not match its observation.");
39
+ }
40
+ const sessionStartup = ["session.started", "session.ready", "host.observed"].includes(kind)
41
+ || (kind === "conversation.observed" && host.startupRunId !== undefined);
42
+ const sessionFact = sessionStartup
43
+ || ["conversation.observed", "goal.updated", "goal.cleared"].includes(kind);
44
+ const directStart = kind === "turn.accepted" && fence.receiptId === `direct:${fence.nativeTurnId}`;
45
+ const additionalInput = kind.startsWith("input.") && (fence.receiptId?.startsWith("native-input:") || fence.receiptId?.startsWith("turn-input:")
46
+ || fence.receiptId?.startsWith("steer:"));
47
+ const terminal = ["turn.completed", "turn.failed", "turn.cancelled", "session.ended", "session.failed"].includes(kind);
48
+ if (fence.taskId === undefined) {
49
+ if (host.startupRunId !== undefined)
50
+ throw new RuntimeHookRunFenceError("Global Host cannot carry a startup Run.");
51
+ const sessions = store.getGlobalRoleSessionSet(fence.roleName);
52
+ const session = sessions?.sessions[fence.agentId];
53
+ if (sessionStartup && (session === undefined || session.status === "ended")) {
54
+ const role = store.getGlobalRole(fence.roleName);
55
+ if (role?.activeAgentId === fence.agentId && role.workspace === host.workspace) {
56
+ throw new AgentHostObservationDeferred("Awaiting exact Global Session adoption.");
57
+ }
58
+ }
59
+ const resolved = resolveRuntimeHookRunFence({
60
+ YUI_SESSION_SCOPE: "global", YUI_ROLE: fence.roleName, YUI_AGENT_ID: fence.agentId,
61
+ YUI_ADAPTER_ID: host.adapterId, YUI_WORKSPACE: host.workspace
62
+ }, host.adapterId, fence.nativeSessionId, {
63
+ ...(sessionFact || directStart ? { sessionOnly: true } : {
64
+ exactInput: true,
65
+ ...(fence.nativeTurnId === undefined ? {} : { nativeTurnId: fence.nativeTurnId }),
66
+ ...(fence.receiptId === undefined || additionalInput ? {} : { attemptId: fence.receiptId })
67
+ })
68
+ }, store);
69
+ return createRuntimeObservation({
70
+ ...input, fence: { ...fence,
71
+ ...(fence.receiptId === undefined && resolved.receiptId !== undefined
72
+ ? { receiptId: resolved.receiptId } : {}) }
73
+ });
74
+ }
75
+ const sessions = store.getTaskRoleSessionSet(fence.taskId, fence.roleName);
76
+ const session = sessions?.sessions[fence.agentId];
77
+ if (kind === "host.observed" && host.startupRunId === undefined
78
+ && (session === undefined || session.status === "ended")) {
79
+ const role = store.getRole(fence.taskId, fence.roleName);
80
+ const task = store.getTask(fence.taskId);
81
+ if (role?.activeAgentId === fence.agentId && role.workspace === host.workspace
82
+ && task !== null && ["active", "draft"].includes(task.status)) {
83
+ // A runless Session launch is adopted by the existing launch coordinator
84
+ // after its Host returns the native id. Retain custody until that commit;
85
+ // it must not itself authorize or invent the Session.
86
+ throw new AgentHostObservationDeferred("Awaiting exact Session adoption.");
87
+ }
88
+ }
89
+ const knownStartup = sessionStartup && store.listEvents(fence.taskId).some(e => e.type === "runtime.observation" && e.payload.eventId === input.eventId);
90
+ const startup = sessionStartup && host.startupRunId !== undefined && !knownStartup;
91
+ const options = {
92
+ ...(fence.nativeTurnId === undefined || sessionFact || directStart ? {} : { nativeTurnId: fence.nativeTurnId }),
93
+ ...(fence.receiptId === undefined || additionalInput || sessionFact || directStart
94
+ ? {} : { attemptId: fence.receiptId }),
95
+ ...(terminal || additionalInput ? { terminal: true } : {}),
96
+ ...((sessionFact && !startup) || directStart || (terminal && fence.receiptId === undefined) ? { sessionOnly: true } : {}),
97
+ ...(!sessionFact && !directStart ? { exactInput: true } : {}),
98
+ ...(startup ? {
99
+ startupRunId: host.startupRunId,
100
+ startupSession: driver.capabilities.observation.sessionBootstrap
101
+ } : {})
102
+ };
103
+ // Idempotent replay of an already applied startup must not be rejected just
104
+ // because its Run has since ended. All raw fields still match the saved fact.
105
+ const resolved = resolveRuntimeHookRunFence({
106
+ YUI_SESSION_SCOPE: "task", YUI_TASK_ID: fence.taskId,
107
+ YUI_ROLE: fence.roleName, YUI_AGENT_ID: fence.agentId,
108
+ YUI_ADAPTER_ID: host.adapterId, YUI_WORKSPACE: host.workspace
109
+ }, host.adapterId, fence.nativeSessionId, options, store);
110
+ return createRuntimeObservation({
111
+ ...input,
112
+ fence: {
113
+ ...fence,
114
+ ...(resolved.runId === undefined ? {} : { runId: resolved.runId }),
115
+ ...(fence.receiptId === undefined && resolved.receiptId !== undefined
116
+ ? { receiptId: resolved.receiptId } : {})
117
+ }
118
+ });
119
+ }
120
+ /** Custody and account evidence share the same exact Session validation and
121
+ * commit/ACK boundary as activity. They contain locations, never credentials. */
122
+ export function recordAgentHostConnection(store, event, input, now) {
123
+ const host = readAgentHostObservationSource(event.host);
124
+ const connection = host.connection;
125
+ if (connection === undefined)
126
+ throw new RuntimeHookRunFenceError("Host connection evidence is missing.");
127
+ const fence = input.fence;
128
+ const owner = connection.processOwner;
129
+ if (owner !== undefined) {
130
+ if (owner.owner.scope !== (fence.taskId === undefined ? "global" : "task")
131
+ || (owner.owner.scope === "task" && owner.owner.taskId !== fence.taskId)
132
+ || owner.owner.roleName !== fence.roleName || owner.agentId !== fence.agentId
133
+ || owner.adapterId !== host.adapterId || owner.nativeSessionId !== fence.nativeSessionId
134
+ || owner.providerRoot.attribution !== "owned-child") {
135
+ throw new RuntimeHookRunFenceError("Host process custody does not match its exact Session.");
136
+ }
137
+ store.saveSessionOwner(owner);
138
+ }
139
+ if (connection.account !== undefined) {
140
+ if (host.adapterId !== "codex")
141
+ throw new RuntimeHookRunFenceError("Native account evidence requires Codex.");
142
+ // Global Roles have no Task event stream. Process custody above is still
143
+ // recorded under the exact Global owner; Task account history stays Task-scoped.
144
+ if (fence.taskId === undefined)
145
+ return;
146
+ const exists = store.listEvents(fence.taskId).some(e => e.type === "runtime.native-connection-bound"
147
+ && e.payload.roleName === fence.roleName && e.payload.agentId === fence.agentId
148
+ && e.payload.nativeSessionId === fence.nativeSessionId);
149
+ if (!exists)
150
+ store.saveEvent(fence.taskId, createTaskEvent(store.nextEventId(fence.taskId), fence.taskId, "runtime.native-connection-bound", {
151
+ roleName: fence.roleName, agentId: fence.agentId, nativeSessionId: fence.nativeSessionId,
152
+ ...connection.account
153
+ }, now));
154
+ }
155
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { findLiveControllerProcessForHome } from "../core/controllerProcessIdentity.js";
3
+ import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "../core/controllerProcessIdentity.js";
4
4
  import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
5
5
  import { callController, ControllerClientError, readControllerDiscovery, stopOrphanedFileTaskController } from "../core/controllerClient.js";
6
6
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
@@ -185,6 +185,9 @@ export async function stopFileTaskController(home, options = {}) {
185
185
  ...(expectedPid === undefined ? {} : { expectedPid }) };
186
186
  try {
187
187
  const result = await stopControllerGracefully(home, bounded);
188
+ if (result.stopped && physical !== undefined) {
189
+ await waitForControllerProcessExit(home, physical, options);
190
+ }
188
191
  if (result.stopped || options.call !== undefined)
189
192
  return result;
190
193
  }
@@ -196,6 +199,18 @@ export async function stopFileTaskController(home, options = {}) {
196
199
  ...(physical === undefined ? {} : { expectedProcessStartIdentity: physical.processStartIdentity }) });
197
200
  return stopped === undefined ? { stopped: false, alreadyStopped: true } : { stopped: true, pid: stopped.pid };
198
201
  }
202
+ async function waitForControllerProcessExit(home, controller, options) {
203
+ const timeoutMs = positive(options.shutdownTimeoutMs, CONTROLLER_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
204
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
205
+ const homeFilesystemId = readHomeFilesystemId(home);
206
+ const deadline = Date.now() + timeoutMs;
207
+ while (inspectLiveControllerProcess(controller.pid, homeFilesystemId, controller.processStartIdentity) !== undefined) {
208
+ if (Date.now() >= deadline) {
209
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Controller process ${controller.pid} did not exit within ${timeoutMs} ms.`);
210
+ }
211
+ await delay(pollMs);
212
+ }
213
+ }
199
214
  async function stopControllerGracefully(home, options) {
200
215
  const call = options.call ?? ((h, method, params) => callController(h, method, params, {
201
216
  timeoutMs: options.requestTimeoutMs
@@ -558,7 +573,7 @@ export class FileTaskWorkflowRuntime {
558
573
  && entry.archiveBlocked));
559
574
  if (blockers.length === 0)
560
575
  return;
561
- throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task archive blocked: ${blockers.length} owned physical resource(s) still live: `
576
+ throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task workspace cleanup blocked: ${blockers.length} owned physical resource(s) live or unverified: `
562
577
  + blockers.map((entry) => (`${entry.owner.roleName}/${entry.nativeSessionId ?? "unknown-session"}`
563
578
  + ` (pid ${entry.physical?.alive === true ? entry.physical.pid : "?"})`)).join("; "));
564
579
  }
@@ -701,6 +701,7 @@ export class FileTaskController {
701
701
  #onMaintenanceFenceDefer;
702
702
  #leaderWakeFence;
703
703
  #jobSupervisor;
704
+ #globalInputDelivery;
704
705
  #continuationReconciler;
705
706
  #current;
706
707
  #operatorCurrent;
@@ -747,6 +748,7 @@ export class FileTaskController {
747
748
  this.#leaderWakeFence = options.leaderWakeFence;
748
749
  this.#jobSupervisor = options.jobSupervisor;
749
750
  this.#continuationReconciler = options.continuationReconciler;
751
+ this.#globalInputDelivery = options.globalInputDelivery;
750
752
  this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
751
753
  windowMs: options.signalWindowMs ?? DEFAULT_SIGNAL_WINDOW_MS,
752
754
  onError: this.#onError,
@@ -986,6 +988,7 @@ export class FileTaskController {
986
988
  }
987
989
  }
988
990
  const firstRuntimeDrain = await this.#drainRuntimeEvents();
991
+ await this.#globalInputDelivery?.();
989
992
  for (const taskId of runtimeTaskFailureIds(firstRuntimeDrain)) {
990
993
  runtimeFailedTaskIds.add(taskId);
991
994
  }