@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
@@ -1,14 +1,53 @@
1
+ import { recordTaskInterruptResult } from "../message/taskInterrupt.js";
2
+ import { recordGlobalInterruptResult, recordGlobalSteerResult } from "../message/globalInterrupt.js";
3
+ import { runGlobalRoleCommand } from "../commands/globalRoleCommands.js";
1
4
  import { readTaskContext, readTaskContextDelta, inspectTaskContext, withContextObservations } from "../context/taskContext.js";
2
5
  import { BUILTIN_CAPABILITIES } from "../kernel/builtinCapabilities.js";
3
6
  import { capabilitySchemaError } from "../kernel/capabilitySchema.js";
4
- import { updateTaskMetadataCommand, sendTaskMessageCommand } from "../commands/taskCommands.js";
7
+ import { updateTaskMetadataCommand, sendTaskMessageCommand, runTaskCommand } from "../commands/taskCommands.js";
5
8
  import { webLocalMutation, WebRequestRejected } from "./webMutation.js";
6
9
  import { runTaskInputCommand } from "../commands/taskInputCommands.js";
10
+ import { sendAgentHostSteerControl, sendAgentHostCancelControl, AGENT_HOST_CONTROL_PROTOCOL, foldSteerLiveReceipt, foldInterruptLiveReceipt } from "../runtime/agentHost.js";
11
+ const DEFAULT_WEB_HOST_CONTROL = {
12
+ steer: sendAgentHostSteerControl,
13
+ cancel: sendAgentHostCancelControl
14
+ };
15
+ /** The store-only CLI argv for the shared application-layer primitive
16
+ * (decision-3 §7). The Web surface never re-implements the queue/steer/interrupt
17
+ * decisions; it drives the exact same command the CLI drives. */
18
+ function controlArgv(taskId, input) {
19
+ if (input.action === "queue") {
20
+ return ["message", "queue", taskId, input.body, "--request-id", input.requestId,
21
+ ...(input.to === undefined ? [] : ["--to", input.to]),
22
+ ...(input.workItem === undefined ? [] : ["--work-item", input.workItem]),
23
+ ...(input.reviewRound === undefined ? [] : ["--review-round", input.reviewRound])];
24
+ }
25
+ if (input.action === "steer") {
26
+ return ["message", "steer", taskId, input.body, "--request-id", input.requestId,
27
+ "--expected-target", input.expectedTarget, "--to", input.to,
28
+ ...(input.workItem === undefined ? [] : ["--work-item", input.workItem]),
29
+ ...(input.reviewRound === undefined ? [] : ["--review-round", input.reviewRound])];
30
+ }
31
+ return ["role", "interrupt", taskId, input.role, "--expected-target", input.expectedTarget,
32
+ ...(input.thenMessage === undefined ? [] : ["--then-message", input.thenMessage]),
33
+ "--request-id", input.requestId];
34
+ }
35
+ /** A queue is delivered to the Leader mailbox only when it is unaddressed or
36
+ * addressed to the Leader; an addressed Worker/Reviewer queue goes to the Task
37
+ * mailbox. A steer/interrupt is a live control op, so it only reconciles the
38
+ * Task. This mirrors the mailbox the core command itself enqueues. */
39
+ function controlNotifiesLeader(input) {
40
+ return input.action === "queue" && (input.to === undefined || input.to === "leader");
41
+ }
42
+ function controlTarget(taskId, input) {
43
+ const roleName = input.action === "interrupt" ? input.role : input.to ?? "leader";
44
+ return { scope: "task", taskId, roleName };
45
+ }
7
46
  /** Installed only by the local-user Web composition root. HTTP authenticates
8
47
  * its token before using this port; input never supplies a caller or Role.
9
48
  * Managed capability RPC keeps its own Session authentication unchanged.
10
49
  */
11
- export function createWebTaskSurface(store, options = {}, observations = []) {
50
+ export function createWebTaskSurface(store, options = {}, observations = [], hostControl = DEFAULT_WEB_HOST_CONTROL) {
12
51
  const environment = {};
13
52
  const commandOptions = { ...options, environment, runtime: undefined };
14
53
  // Notifications are after the outer transaction. Their failure must not be
@@ -22,10 +61,83 @@ export function createWebTaskSurface(store, options = {}, observations = []) {
22
61
  options.runtime?.notifyStateChanged(taskId);
23
62
  };
24
63
  return {
64
+ globalState: (roleName) => {
65
+ const role = store.getGlobalRole(roleName);
66
+ if (role === null)
67
+ throw new WebRequestRejected("Global Role not found.");
68
+ const sessions = store.getGlobalRoleSessionSet(roleName);
69
+ return {
70
+ roleName,
71
+ nativeSessionId: sessions?.sessions[sessions.activeAgentId]?.nativeSessionId,
72
+ turn: sessions?.providerBinding?.run ?? null,
73
+ authority: sessions?.providerBinding?.authority ?? null,
74
+ interrupts: sessions?.interrupts ?? {},
75
+ messages: store.listGlobalRoleMessages(roleName).map(message => ({
76
+ id: message.id, body: message.body, inputControl: message.inputControl,
77
+ control: message.control, delivery: message.delivery, notDelivered: message.notDelivered
78
+ }))
79
+ };
80
+ },
81
+ globalControl: async (roleName, input) => {
82
+ const argv = input.action === "interrupt"
83
+ ? ["interrupt", roleName, "--request-id", input.requestId, "--expected-target", input.expectedTarget,
84
+ ...(input.thenMessage === undefined ? [] : ["--then-message", input.thenMessage])]
85
+ : ["message", input.action, roleName, input.body, "--request-id", input.requestId,
86
+ ...(input.action === "steer" ? ["--expected-target", input.expectedTarget] : [])];
87
+ const result = webLocalMutation(store, tx => runGlobalRoleCommand(argv, tx, {
88
+ env: {}, yuiHome: options.yuiHome, jsonOutput: true
89
+ }));
90
+ if (typeof result === "string") {
91
+ if (input.action === "queue")
92
+ void options.runtime?.notifyMailboxChanged?.({
93
+ kind: "global-role-runtime", roleName
94
+ });
95
+ return { action: input.action, ...JSON.parse(result) };
96
+ }
97
+ if (result.kind !== "input-steer" && result.kind !== "input-interrupt") {
98
+ throw new WebRequestRejected("Global input cannot perform a Session lifecycle operation.");
99
+ }
100
+ if (options.yuiHome === undefined)
101
+ throw new Error("Global control requires a configured Yui Home.");
102
+ if (result.kind === "input-steer") {
103
+ let control;
104
+ try {
105
+ control = await hostControl.steer({
106
+ home: options.yuiHome, scope: "global", roleName,
107
+ control: { protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "steer-turn",
108
+ nativeSessionId: result.target.nativeSessionId, nativeTurnId: result.target.nativeTurnId,
109
+ authority: result.target.authority,
110
+ run: { attemptId: result.receiptId, boundedText: result.text } }
111
+ });
112
+ }
113
+ catch (error) {
114
+ recordGlobalSteerResult(store, roleName, result.messageId, { state: "steer-unknown", outcome: "pending" });
115
+ throw error;
116
+ }
117
+ const steer = foldSteerLiveReceipt(control);
118
+ recordGlobalSteerResult(store, roleName, result.messageId, steer);
119
+ return { action: "steer", roleName, messageId: result.messageId, steer };
120
+ }
121
+ let control;
122
+ try {
123
+ control = await hostControl.cancel({
124
+ home: options.yuiHome, scope: "global", roleName,
125
+ control: { protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "cancel", nativeOnly: true,
126
+ nativeSessionId: result.target.nativeSessionId,
127
+ authority: result.target.authority, attemptId: result.target.attemptId }
128
+ });
129
+ }
130
+ catch (error) {
131
+ recordGlobalInterruptResult(store, roleName, result.receiptId, { state: "interrupt-unknown", outcome: "cancel-requested" });
132
+ throw error;
133
+ }
134
+ const interrupt = foldInterruptLiveReceipt(control);
135
+ recordGlobalInterruptResult(store, roleName, result.receiptId, interrupt);
136
+ return { action: "interrupt", roleName, receiptId: result.receiptId, interrupt };
137
+ },
25
138
  message: (taskId, body, intent, requestId) => {
26
- // With no explicit intent the Web surface submits `discuss` like every other
27
- // client (§2.5), through the one shared service; the requestId is threaded as
28
- // the submission key (§2.3) and the structured feedback is returned verbatim.
139
+ // Preserve the submission's intent and frozen receipt separately from
140
+ // queue/steer controls; an omitted intent still means discuss.
29
141
  const { message, task, queuedForLeader, feedback } = webLocalMutation(store, (tx) => sendTaskMessageCommand(tx, taskId, body, undefined, commandOptions, undefined, intent, requestId));
30
142
  notify(taskId, queuedForLeader);
31
143
  return { record: message, revision: message.createdAt,
@@ -34,6 +146,111 @@ export function createWebTaskSurface(store, options = {}, observations = []) {
34
146
  ...(feedback === undefined ? {} : { submission: feedback }),
35
147
  target: { scope: "task", taskId, roleName: "leader" } };
36
148
  },
149
+ /**
150
+ * The decision-3 three-action input-control path for the local-user Web
151
+ * surface. Its store-only phase is the identical shared application-layer
152
+ * primitive the CLI uses (`runTaskCommand`), run inside `webLocalMutation`
153
+ * so a rejected input is provably not-submitted. A ready steer/interrupt
154
+ * returns a live intent; the single Agent Host edge then runs OUTSIDE the
155
+ * transaction exactly as cli.ts performs it — never a fallback, retarget, or
156
+ * fourth action. A committed input whose live edge fails is delivery-unknown,
157
+ * not not-submitted: it throws a plain error so the receipt is "unknown" and
158
+ * the durable Message is retained (decision-3 §1/§3/§5, message-5 gap F).
159
+ */
160
+ control: async (taskId, input) => {
161
+ const execution = webLocalMutation(store, (tx) => runTaskCommand(controlArgv(taskId, input), tx, commandOptions));
162
+ if (execution.kind === "output") {
163
+ // A queue receipt, or a steer/interrupt that was saved-but-not-delivered
164
+ // or an idempotent replay: fully durable, no live edge, exact disposition.
165
+ notify(taskId, controlNotifiesLeader(input));
166
+ const data = execution.data;
167
+ const settlement = data.delivery ?? data.steer ?? data.interrupt;
168
+ return {
169
+ action: input.action, disposition: settlement?.state ?? "saved",
170
+ target: controlTarget(taskId, input),
171
+ ...(data.message === undefined ? {} : { record: data.message, revision: data.message.createdAt }),
172
+ ...(data.delivery === undefined ? {} : { delivery: data.delivery }),
173
+ ...(data.steer === undefined ? {} : { steer: data.steer }),
174
+ ...(data.interrupt === undefined ? {} : { interrupt: data.interrupt })
175
+ };
176
+ }
177
+ // A resolved live control op. Core has persisted the Message (steer) and
178
+ // recorded the one `pending` control attempt; both are already committed.
179
+ const home = options.yuiHome;
180
+ if (home === undefined) {
181
+ throw new Error("Live Agent Host control requires a configured Yui home.");
182
+ }
183
+ if (execution.kind === "input-steer") {
184
+ let control;
185
+ try {
186
+ control = await hostControl.steer({
187
+ home, scope: "task", taskId: execution.taskId, roleName: execution.roleName,
188
+ control: {
189
+ protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "steer-turn",
190
+ nativeSessionId: execution.target.nativeSessionId,
191
+ nativeTurnId: execution.target.nativeTurnId ?? execution.target.attemptId,
192
+ authority: execution.target.authority,
193
+ run: { attemptId: execution.receiptId, boundedText: execution.text }
194
+ }
195
+ });
196
+ }
197
+ catch (error) {
198
+ throw new Error(`Steer message ${execution.messageId} is saved but the native steer did not `
199
+ + `complete: ${error instanceof Error ? error.message : String(error)}. The Message is `
200
+ + "retained and its outcome is recorded from the Host; whether the Provider accepted it may "
201
+ + "be delivery-unknown. Re-read the Session before acting; do not reissue the same input "
202
+ + "under a new requestId or a different action.");
203
+ }
204
+ notify(taskId);
205
+ // decision-3 §7 live acceptance: fold the actual Host outcome rather than
206
+ // presume success. `steered` is the only proven delivery; pending is
207
+ // delivery-unknown; rejected/unavailable did not deliver. No fallback.
208
+ const steer = foldSteerLiveReceipt(control);
209
+ return { action: "steer", disposition: steer.state,
210
+ taskId, roleName: execution.roleName, messageId: execution.messageId,
211
+ target: { scope: "task", taskId, roleName: execution.roleName },
212
+ steer };
213
+ }
214
+ let control;
215
+ if (execution.kind !== "input-interrupt") {
216
+ // The three-action argv only ever yields output/input-steer/input-interrupt;
217
+ // any other intent means the shared command was mis-dispatched, not a
218
+ // control outcome to fold. Fail closed rather than guess.
219
+ throw new Error(`Unexpected control execution kind: ${execution.kind}.`);
220
+ }
221
+ try {
222
+ control = await hostControl.cancel({
223
+ home, scope: "task", taskId: execution.taskId, roleName: execution.roleName,
224
+ control: {
225
+ protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "cancel",
226
+ nativeOnly: true,
227
+ nativeSessionId: execution.target.nativeSessionId,
228
+ // Native cancel names the exact original execution attempt it stops,
229
+ // never the durable receiptId of this interrupt operation.
230
+ attemptId: execution.target.attemptId,
231
+ authority: execution.target.authority
232
+ }
233
+ });
234
+ }
235
+ catch (error) {
236
+ recordTaskInterruptResult(store, execution.taskId, execution.receiptId, { state: "interrupt-unknown", outcome: "cancel-requested" });
237
+ throw new Error(`Interrupt ${execution.receiptId} of ${execution.taskId}/${execution.roleName} did not complete: `
238
+ + `${error instanceof Error ? error.message : String(error)}. No process was killed; `
239
+ + "re-read the Session before retrying.");
240
+ }
241
+ notify(taskId);
242
+ // The proof is `control.cancellation`, not the bare `cancel-requested`
243
+ // outcome: only a proven stop-request is `interrupted`. A then-handoff, if
244
+ // any, was already claimed durably by Core and is delivered once by the
245
+ // ordinary continuation path — never re-driven from this receipt.
246
+ const interrupt = foldInterruptLiveReceipt(control);
247
+ recordTaskInterruptResult(store, execution.taskId, execution.receiptId, interrupt);
248
+ return { action: "interrupt", disposition: interrupt.state,
249
+ taskId, roleName: execution.roleName,
250
+ target: { scope: "task", taskId, roleName: execution.roleName },
251
+ ...(execution.thenMessageId === undefined ? {} : { thenMessageId: execution.thenMessageId }),
252
+ interrupt };
253
+ },
37
254
  read: async (taskId) => withContextObservations(readTaskContext(store, taskId, environment), observations),
38
255
  delta: (taskId, input) => readTaskContextDelta(store, taskId, input, environment),
39
256
  inspect: (taskId, input) => inspectTaskContext(store, taskId, input, environment),
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { lstat } from "node:fs/promises";
2
3
  import { createWorkItemChangeSet } from "../integration/changeSet.js";
3
4
  import { createChangeSetManifest } from "../integration/changeSetManifest.js";
4
5
  import { deriveManifestTags } from "../integration/manifestTags.js";
@@ -60,11 +61,25 @@ export class WorkItemChangeSetManager {
60
61
  const git = new NodeGitWorkspace();
61
62
  const projects = [];
62
63
  for (const entry of writableEntries(workspace)) {
63
- if (!await git.isClean(entry.path)) {
64
+ const path = await lstat(entry.path).catch(error => {
65
+ if (error.code === "ENOENT")
66
+ return null;
67
+ throw error;
68
+ });
69
+ if (path?.isSymbolicLink())
70
+ throw new Error(`WorkItem path is a symbolic link: ${entry.path}.`);
71
+ if (path !== null && !await git.isClean(entry.path)) {
64
72
  throw new Error(`WorkItem Project workspace is not clean: ${item.id}/${entry.projectId}.`);
65
73
  }
66
- const workspaceHeadCommit = (await git.inspect(entry.path, "HEAD")).baseCommit;
67
74
  const resultCommit = candidate?.gitSnapshot?.projects.find(({ projectId }) => projectId === entry.projectId)?.commit;
75
+ // Absence is a filesystem fact, not proof of integration or Git cleanup.
76
+ // Check any retained branch against the same frozen Candidate; the
77
+ // cleanup primitive separately removes its exact Git registration.
78
+ const repository = this.store.getTaskWorkspace(taskId)?.entries.find(e => e.projectId === entry.projectId);
79
+ const workspaceHeadCommit = path !== null ? (await git.inspect(entry.path, "HEAD")).baseCommit
80
+ : repository !== undefined && await git.refExists(repository.path, entry.branch)
81
+ ? (await git.inspect(repository.path, entry.branch)).baseCommit
82
+ : resultCommit;
68
83
  if (candidate?.workspace === undefined
69
84
  || !isDeepStrictEqual(candidate.workspace, workspace)
70
85
  || resultCommit === undefined
@@ -109,6 +124,7 @@ export class WorkItemChangeSetManager {
109
124
  }
110
125
  const unresolved = this.store.listIntegrationAttempts(task.id).find((attempt) => (attempt.status === "running"
111
126
  || attempt.status === "blocked"
127
+ || attempt.status === "conflicted"
112
128
  || attempt.status === "validating"));
113
129
  if (unresolved !== undefined) {
114
130
  throw new Error(`Task has an unresolved Integration Attempt: ${task.id}/${unresolved.id}.`);
@@ -74,6 +74,45 @@ Candidate and Task-final ReviewRound records must all carry that one contract.
74
74
  Conflicting records fail closed; there is no rebind event, recovery command, or
75
75
  second contract state machine.
76
76
 
77
+ ## Persistent Agent Host compatibility
78
+
79
+ A running Host keeps its original Endpoint implementation. It records exact
80
+ Session/attempt/native-Turn facts in the existing durable Inbox before contacting
81
+ the Controller; only the current Controller resolves Run ownership, validates
82
+ authority/workspace/lifecycle/history, and commits the result. An Inbox file is
83
+ not acceptance. Files are consumed only after commit, so downtime or a lost ACK
84
+ does not replay user input or model work.
85
+
86
+ Startup facts use the exact Run identity from the redeemed launch payload, not
87
+ the long-lived Session environment. This preserves pre-adoption evidence even
88
+ when the frozen Run workspace differs from the Role's default; later activity
89
+ and terminal facts still resolve solely by their own native input identities.
90
+
91
+ The supported Host boundary is control `yui-agent-host/v5`, event source
92
+ `yui-agent-host-events/v1`, and Controller RPC version 4. Hosts advertising
93
+ `storage=controller-owned` do not open the Home database, including for process
94
+ custody, native account locations, or execution-environment checks. Home 22
95
+ declares the additive Inbox source envelope; valid older Inbox v1 facts and
96
+ domain history remain readable. Future changes must retain this wire boundary
97
+ or reject incompatible live producers before changing storage. CLI wrapper
98
+ refresh and a successful new `doctor` are not Host compatibility proofs.
99
+
100
+ `upgrade`, the staged target's `update` preflight, and release activation inspect
101
+ live Host capabilities independently. An old Host without this capability,
102
+ including an idle Host or one whose response is unconfirmed, blocks adoption.
103
+ Checks are repeated at the existing fenced/quiesced handover boundary before
104
+ migration or promotion. No Host is killed, replaced, or reloaded by these checks.
105
+ Let existing work settle and preserve original pending input/result evidence;
106
+ then an authorized Operator can select a safe Session replacement/cleanup before
107
+ retrying. Installing this change cannot repair already-loaded legacy Host code
108
+ or collect a terminal that that code never durably emitted.
109
+
110
+ `task role status`, `task role list`, and `task role session inspect` expose Host
111
+ reporting alongside durable Run state. Pending native results or known reporting
112
+ failures require attention; they do not mean the Provider failed, the Run ended,
113
+ or the work was accepted. The Host's live diagnostics inspect only its own event
114
+ file identities and never parse or quarantine another producer's newer payload.
115
+
77
116
  ## CLI and Controller release boundary
78
117
 
79
118
  The global `yui` command is the stable user and managed-Session interface. It
@@ -59,6 +59,35 @@ Session 使用普通的 `yui` 命令,兼容性由协议和存储身份检查
59
59
  Task-final 的 ReviewRound 记录必须全部携带那唯一的合同。冲突的记录 fail closed;
60
60
  不存在重新绑定事件、恢复命令或第二套合同状态机。
61
61
 
62
+ ## 常驻 Agent Host 升级兼容
63
+
64
+ 存活 Host 保持原 Endpoint 实现,先把准确的 Session/attempt/nativeTurn 事实写入现有
65
+ 持久 Inbox,再联系 Controller。只有当前 Controller 解析 Run 归属、校验权限、工作区、
66
+ 生命周期与历史关联并提交结果。Inbox 文件不代表接受;提交后才确认消费,断线或丢失
67
+ ACK 不会重放用户输入或模型工作。
68
+
69
+ 启动事实从已兑现的 launch payload 取得准确 Run 身份,不把 Run 固定到长期 Session
70
+ 环境。即使冻结的 Run 工作区不同于 Role 默认值,登记前的证据也能保留;后续活动和
71
+ 终态仍只按各自的原生输入身份解析归属。
72
+
73
+ 支持边界为控制协议 `yui-agent-host/v5`、事件来源协议 `yui-agent-host-events/v1`、
74
+ Controller RPC 版本 4。声明 `storage=controller-owned` 的 Host 不打开 Home 数据库,
75
+ 包括进程归属、原生账号位置和执行环境校验。Home 22 声明 Inbox 的新增来源字段,
76
+ 不改写有效历史事实与业务记录。后续版本要么保留该线协议,要么在修改存储前拒绝不兼容
77
+ 的存活生产者。刷新 Session CLI wrapper 或新 `doctor` 成功都不能证明旧 Host 兼容。
78
+
79
+ `upgrade`、`update` 的目标版本预检及 release activation 独立检查存活 Host。
80
+ 没有此能力的 legacy Host(包括 idle)以及兼容响应不确定的 Host 都阻止采用;
81
+ 在既有隔离/静默交接边界再次检查,先于迁移或发布切换。检查不会 kill、替换或 reload
82
+ Host。先让原有工作结束并保留原始未决输入/结果证据,再由获授权的 Operator 在安全
83
+ 边界选择 Session 替换或清理,之后重试。安装新代码不能修改已加载的 legacy Host 内存,
84
+ 也不能回收它从未持久投递过的终态。
85
+
86
+ `task role status`、`task role list` 和 `task role session inspect` 在持久 Run 状态旁
87
+ 显示 Host 上报观测。原生结果待落库或已知上报故障需要关注,但不代表 Provider 失败、
88
+ Run 已结束或业务已验收。Host 诊断只检查自己已投递文件的身份是否存在,不解析或隔离
89
+ 其他生产者的新格式事件。
90
+
62
91
  ## CLI 与 Controller 发布边界
63
92
 
64
93
  全局 `yui` 命令是稳定的用户与受管 Session 接口。对普通命令,它不跟随
@@ -69,10 +69,232 @@ Every persistent schema or payload change appends one immutable, contiguous
69
69
  storage migration. The CLI publishes both `storageVersion` and
70
70
  `minimumStorageVersion`; every valid Home in that inclusive range can upgrade
71
71
  directly to the current version without installing intermediate releases.
72
- The current source declares storage version **18**, with minimum supported
72
+ The current source declares storage version **25**, with minimum supported
73
73
  migration version **1**, in `src/storage/storageVersions.ts`. Homes below that
74
74
  floor are not migration inputs and remain untouched.
75
75
  The target binary's `upgrade --update-preflight` and `--update-apply` result
76
76
  shapes and parent-owned handover-lock proof remain backward compatible with
77
77
  every updater released from storage version 1 onward, so an old source CLI can
78
78
  still drive a much newer target's complete migration chain.
79
+
80
+ ## Unified Home layout
81
+
82
+ Every Yui self-managed directory lives under the single canonical `YUI_HOME`
83
+ (default `~/.yui`; an explicit `YUI_HOME` is honoured verbatim). `YUI_HOME` is
84
+ never inferred from the current working directory and never substituted with a
85
+ username. `src/storage/homeLayout.ts` is the one authority that derives each
86
+ managed root from Home:
87
+
88
+ | Root | Path | Holds |
89
+ |---|---|---|
90
+ | Managed worktrees | `<home>/workspaces/tasks/<taskId>/<owner>/<projectDirectory>` | Actual Task/WorkItem/Review/Integration Git directories, addressed by the bound Project directory. Owners are `main`, `work-items/<id>`, `reviews/<id>`, `integrations/<id>` and `execution-lanes/<group>/<lane>`. |
91
+ | Read-only context views | Within the same owner directory | Regenerable symlinks to read-only Project context only; writable entries are actual Git directories, not links. |
92
+ | Global Role workspace | `<home>/workspaces/global` | Default cwd for Yui-auto-created Global Roles (the `yui setup` Operator/Leader and ad-hoc Global Roles added without an explicit `--workspace`). A plain cwd, not a managed Git workspace. |
93
+ | Task provider runtimes | `<home>/runtime/task-runtimes` | Task provider data/cache/tmp; also the planning cwd at `…/planning/<taskId>`. |
94
+ | Integration runtimes | `<home>/runtime/integration-runtimes` | The integration check's provider data/cache/tmp (a separate partition from Task runtimes). |
95
+ | Update staging | `<home>/runtime/update-staging` | `yui update`'s side-by-side package install (an upgrade artifact). |
96
+ | Release workflow scratch | `<home>/runtime/release-workflow` | The release workflow's smoke-install dir and verified publish-snapshot tarball (release artifacts). |
97
+ | Storage backups | `<home>/backups` | Pre-upgrade DB backups (the fenced upgrade's rollback anchor). |
98
+
99
+ Published migrations 1–23 remain unchanged, including Task artifacts in local
100
+ Git (19), Integration continuation (20), force-archive evidence (21), and
101
+ Controller-owned Host ingress (22), and unified message input control (23).
102
+ The two offline layout steps are now 23→24 (`unify-home-layout`) and
103
+ 24→25 (`collapse-worktree-layout`). Version 24's
104
+ `workspaces/worktree` directory is an intermediate layout, not a second live
105
+ root at version 25. A single upgrade applies the full pending chain.
106
+
107
+ Stop this Home's writers and take a backup before upgrading. The layout steps
108
+ copy and verify the registered Git trees, repair only the copies' links, and
109
+ preserve old sources for manual recovery. Version 25 replaces registered
110
+ Task-view symlinks with real writable directories; unrelated Task scratch is
111
+ retained. Read-only context remains a view and can be promoted to a writable
112
+ worktree when WorkItem scope expands. Do not delete the old sources until the
113
+ new layout is verified; a failed upgrade requires manual residue cleanup and
114
+ backup recovery, not automatic resume.
115
+
116
+ Both runtime partitions (`runtime/task-runtimes`, `runtime/integration-runtimes`)
117
+ are the ONLY Home subtrees a provider runtime root is allowed to overlap; a
118
+ runtime root overlapping any other part of Home (the database, `workspaces/`,
119
+ `projects/`) is still rejected by `assertTaskRuntimeIsolationPreflight`, so
120
+ unifying the root does not weaken control-data or cross-owner isolation.
121
+
122
+ `defaultWorkspace` is a user-facing cwd for external Project input only; it is
123
+ **not** a second authority for internal managed paths, and is intentionally not
124
+ an input to `homeLayout.ts`. A Yui-auto-created Global Role that carries no
125
+ user-chosen cwd no longer falls back to it (or to `process.cwd()`): `yui setup`'s
126
+ built-in Operator/Leader and `yui role add` without `--workspace` now default to
127
+ the Home-internal `managedGlobalRoleWorkspace(home)` (`<home>/workspaces/global`),
128
+ and `setup` no longer fabricates an external Home-sibling `workspace/` — a
129
+ `default-workspace` is persisted only if the user configured one. A user who
130
+ *names* an external directory (explicit `--workspace`, or a configured
131
+ `default-workspace`) keeps external-resource semantics; the outside-Home guard
132
+ still applies to it. The "planning/global cwd" that criterion 1 places under Home
133
+ is thus both the *disposable runtime cwd Yui materializes itself* — the Draft
134
+ planning cwd (`planningRuntimeCwd`, under `runtime/task-runtimes/planning`) — and
135
+ the auto-created Global Role cwd above; only an operator's *explicitly named*
136
+ external directory stays outside by design.
137
+
138
+ Only genuine short-path IPC socket ENDPOINTS remain outside Home, and only
139
+ because a Unix-domain `sockaddr_un` path has a small fixed length budget that a
140
+ deep Home path would exceed. Each is a single socket path, never a data/cache/tmp
141
+ root:
142
+
143
+ - the Controller socket (`/tmp/yui-<uid>/<homeId>.sock`),
144
+ - the tmux server socket (`/tmp/tmux-<uid>` via the tmux namespace),
145
+ - the Agent Host socket (`/tmp/yui-<uid>/agent-host/…sock`), and
146
+ - the integration check's tmux socket dir (`/tmp/yi-<uid>-<digest>`), bound only
147
+ into `TMUX_TMPDIR`.
148
+
149
+ The integration check's ordinary runtime state is **not** an exception: its
150
+ provider data, cache, and temp roots live in the Home partition above
151
+ (`runtime/integration-runtimes`); `TMPDIR`/`TMP`/`TEMP` point there, and only
152
+ `TMUX_TMPDIR` is redirected to the short `/tmp` socket dir.
153
+
154
+ ## Migration 23 → 24: unify managed paths under Home
155
+
156
+ Historically the managed worktrees lived under the out-of-Home
157
+ `defaultWorkspace` (`<ws>/worktree`, `<ws>/tasks`) and the provider runtimes
158
+ under a string-built Home sibling (`<home>.task-runtimes`). The one forward
159
+ migration `unify-home-layout` (`src/storage/migrations/unifyHomeLayout.ts`)
160
+ brings that content under Home and rewrites the persisted absolute pointers the
161
+ runtime dereferences as live, without re-cloning Git content or renaming the
162
+ path-independent Git refs. It runs as the migration's `migrateData` step inside
163
+ the upgrade transaction, so schema and data advance atomically or roll back
164
+ together.
165
+
166
+ **Exactly one tree is physically relocated: the managed Git worktree tree.** It
167
+ is the sole subtree that holds durable, non-regenerable content (committed **and**
168
+ uncommitted work), so it alone is copied on disk. Everything else that "moves"
169
+ moves only by pointer:
170
+
171
+ - the per-Task symlink views (`<ws>/tasks`) are regenerable — the pointer is
172
+ rewritten and `ensureWorkspaceView` rebuilds the links at the next launch;
173
+ - the provider runtimes (`<home>.task-runtimes`) are disposable — the pointer is
174
+ rewritten and the roots are recreated at the next launch.
175
+
176
+ The worktree copy is **non-destructive and verified** (see *Recovery and
177
+ rollback*): the source is copied (never renamed away), the replica's content
178
+ digest is checked against the source, and only a verified replica is atomically
179
+ published. The original worktree tree is **preserved** as the rollback anchor;
180
+ removing it is a later, authorized, post-restart cleanup step, never part of this
181
+ transaction.
182
+
183
+ The pointer rewrite is **surgical, not a table sweep** — only records the runtime
184
+ treats as live launch pointers are touched:
185
+
186
+ - `managed_workspaces` — the authoritative registry (`path` column, payload
187
+ `root`, every `entries[].path`). Every surviving row is live (dispositioned
188
+ rows are deleted at cleanup).
189
+ - active (`status='active'`) `turns` — **both** `run.effective.workspace` (the
190
+ actual OS launch cwd source) and the `run.workspace` mirror, rewritten together
191
+ because `validateRun` requires them to stay identical; a run's
192
+ `.result.systemEvidence.workspaceSnapshot` is frozen Git evidence and is left
193
+ byte-for-byte intact.
194
+ - `role_session_sets` / `global_role_session_sets` — each live session's
195
+ `effective.workspace` in the `sessions` map; terminal sessions in `history` are
196
+ preserved.
197
+ - `review_rounds` — the mirrored workspace (only while its `managed_workspaces`
198
+ owner row still exists) and each OPEN execution lane; an orphaned mirror or a
199
+ terminal lane is frozen evidence and is preserved.
200
+ - `work_items` — each OPEN execution lane inside `executionGroups`; candidate
201
+ snapshots (`work_item_candidates`) are frozen and preserved.
202
+ - `task_roles.workspace` — the live launch cwd, including a Draft's planning Role
203
+ under the old runtime sibling (never self-healed until activation).
204
+ - `task_records.cwd` — self-heals on the next `prepareTaskWorkspace`, but is
205
+ rewritten defensively to close the stale-read window.
206
+
207
+ Everything else is preserved on purpose: `context_snapshots`, terminal `turns`
208
+ (with their system evidence), terminal sessions, `work_item_candidates`, terminal
209
+ execution lanes, terminal `durable_jobs`, `events`, and reports are frozen
210
+ history. `resource_registry` is re-discovered from disk; `projects.path` is an
211
+ external, user-owned checkout.
212
+
213
+ The migration is applied **offline** and is **fail-closed and pre-checkable**.
214
+ It is run by the standalone `yui upgrade` boundary AFTER the operator has stopped
215
+ this Home's Controller, Agent Host, and any execution/Job writers; it does not
216
+ orchestrate that shutdown, coordinate an online write-stop, or migrate a live
217
+ Session. It keeps only the minimal preconditions it can implement directly:
218
+
219
+ - It **refuses** if a queued or running `durable_jobs` step is bound to a tree
220
+ about to relocate. A durable Job's runner is detached and could outlive an
221
+ incompletely stopped Controller, so moving that tree would risk an in-flight
222
+ silent move; this is the one residual runtime signal the offline migration
223
+ still guards. Let the Job drain or cancel it, then re-run the upgrade.
224
+ (`active_turns` is steady state, not an in-flight signal, and is deliberately
225
+ not consulted.)
226
+ - It **refuses** if a relocation target already exists at all — it is either a
227
+ foreign directory or residue from a failed prior run, and the offline migration
228
+ never adopts a pre-existing target. Confirm the source is intact, then move or
229
+ remove the target and re-run the upgrade.
230
+ - Every refusal is surfaced as a **collected, read-only pre-check**: `yui
231
+ upgrade --dry-run` and the updater's `--update-preflight` run the same plan and
232
+ the same blocking conditions execute would throw on, opening the DB read-only
233
+ and reporting each independent blocker as `{reason, detail}` (blocked outcome)
234
+ without mutating the Home — a genuine pre-check, not a best-effort guess.
235
+ - A Home already in the unified layout (or a fresh Home with nothing to relocate)
236
+ is a **no-op**.
237
+
238
+ ### Recovery and rollback
239
+
240
+ The migration keeps **no recovery manifest and no resumable state machine** — it
241
+ is a one-time offline transform, not an interruptible online orchestration. The
242
+ worktree relocation is **copy → digest-verify → atomic-publish → preserve-source**:
243
+
244
+ 1. the relocation target must not already exist; a pre-existing target is refused
245
+ up front (foreign directory or failed-run residue — never adopted);
246
+ 2. the source is copied into a same-filesystem staging dir (`<to>.incoming`),
247
+ never renamed away;
248
+ 3. a content-addressed inventory digest of the replica is compared to the source
249
+ — a mismatch deletes the staging copy and aborts (nothing published, source
250
+ intact);
251
+ 4. only a verified replica is `rename`d into the final target (atomic on one
252
+ filesystem);
253
+ 5. the original source tree is left in place as the rollback anchor.
254
+
255
+ There is **no automatic idempotent recovery**. Because a pre-existing target is
256
+ always refused, a run interrupted after a partial publish does not silently
257
+ resume or adopt the partial tree on the next attempt: the operator inspects the
258
+ preserved source, removes the incomplete target (and any `<to>.incoming`
259
+ staging), and re-runs the upgrade from a clean state. The `--dry-run` /
260
+ `--update-preflight` pre-check surfaces exactly this `target-conflict` before the
261
+ apply transaction is entered, so the residue is reported, not discovered
262
+ mid-migration.
263
+
264
+ After the copy, the worktrees are reconnected. `git worktree repair` chases the
265
+ absolute pointer files inside a worktree, so running it on a verbatim copy whose
266
+ pointers still address the OLD source would rewrite the OLD source's `.git`
267
+ files and corrupt the rollback anchor. The migration therefore **relinks first**:
268
+ it deterministically repoints, in the NEW copy only, the two cross-reference
269
+ pointer files (a linked worktree's `.git` stub and each
270
+ `main/.git/worktrees/<name>/gitdir`) from OLD to NEW, and only THEN runs `git
271
+ worktree repair` from each main clone at its new path as a belt-and-braces
272
+ reconciliation now confined to the new tree. This keeps the preserved source a
273
+ fully independent, working Git: its `.git` is byte-for-byte unchanged and it
274
+ still resolves HEAD/index/status after the migration (verified empirically on a
275
+ private disposable Home). **A repair failure is fatal** — it aborts the migration
276
+ so the transaction rolls back rather than advancing the version over unrepaired
277
+ worktrees.
278
+
279
+ Because the data step runs inside the upgrade transaction, any throw rolls the
280
+ schema back to its original version; the fenced upgrade orchestrator additionally takes a
281
+ `database.backup()` and restores it on failure. Recovery from a failed run is
282
+ **manual, not automatic**: because the source is never removed and the copy is
283
+ digest-verified before publish, the preserved source is always intact, so the
284
+ operator clears any partial target and re-runs the upgrade. No re-run can lose or
285
+ corrupt the original content, but the tool does not itself resume an interrupted
286
+ move set.
287
+
288
+ **Old-source cleanup** is intentionally deferred and out of band: after a
289
+ successful upgrade the old external `worktree`, `tasks`, and `<home>.task-runtimes`
290
+ roots are left **in place** (not emptied) until an operator-authorized cleanup
291
+ removes them. This keeps a full rollback anchor available across the first
292
+ restart.
293
+
294
+ **Rollback limits:** once the Controller restarts against the unified layout and
295
+ begins writing new records under Home, restoring the pre-upgrade DB backup no
296
+ longer matches the newly written on-disk state. Until that first post-upgrade
297
+ write, the preserved old source plus the DB backup are a complete rollback pair;
298
+ after it, the supported recovery is forward (the layout is already unified), not a
299
+ downgrade to the split layout. Verify an upgrade only on a private, disposable
300
+ Home before applying it to a shared environment.