@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.
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/cli/commandCatalog.js +26 -6
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +336 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +511 -83
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/taskContext.js +38 -2
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +3 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +136 -4
- package/dist/storage/sqliteStore.js +40 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/web/assets/client/app.js +55 -0
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +106 -0
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +117 -1
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/testing/verification-levels.md +24 -0
- package/docs/testing/verification-levels.zh-CN.md +11 -0
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +151 -49
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +6 -1
- package/skills/yui-runtime/SKILL.md +27 -0
- 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
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
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}.`);
|
package/docs/release-workflow.md
CHANGED
|
@@ -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 **
|
|
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.
|