@yu_robotics/remote-cli 1.6.134 → 1.6.137
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/README.md +46 -0
- package/dist/client/MessageHandler.d.ts +2 -0
- package/dist/client/MessageHandler.d.ts.map +1 -1
- package/dist/client/MessageHandler.js +60 -6
- package/dist/client/MessageHandler.js.map +1 -1
- package/dist/client/WebSocketClient.d.ts.map +1 -1
- package/dist/client/WebSocketClient.js +1 -0
- package/dist/client/WebSocketClient.js.map +1 -1
- package/dist/delegation/DelegatedWorkerLaneCleanup.d.ts +2 -0
- package/dist/delegation/DelegatedWorkerLaneCleanup.d.ts.map +1 -1
- package/dist/delegation/DelegatedWorkerLaneCleanup.js +13 -0
- package/dist/delegation/DelegatedWorkerLaneCleanup.js.map +1 -1
- package/dist/delegation/DelegatedWorkerSessionStore.d.ts +4 -0
- package/dist/delegation/DelegatedWorkerSessionStore.d.ts.map +1 -1
- package/dist/delegation/DelegatedWorkerSessionStore.js +32 -1
- package/dist/delegation/DelegatedWorkerSessionStore.js.map +1 -1
- package/dist/delegation/DelegatedWorkspaceManager.d.ts +30 -0
- package/dist/delegation/DelegatedWorkspaceManager.d.ts.map +1 -1
- package/dist/delegation/DelegatedWorkspaceManager.js +469 -23
- package/dist/delegation/DelegatedWorkspaceManager.js.map +1 -1
- package/dist/delegation/DelegationManager.d.ts +10 -0
- package/dist/delegation/DelegationManager.d.ts.map +1 -1
- package/dist/delegation/DelegationManager.js +138 -6
- package/dist/delegation/DelegationManager.js.map +1 -1
- package/dist/maintenance/release-notes.json +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.DelegatedWorkspaceManager = void 0;
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
|
+
const fs_1 = require("fs");
|
|
8
9
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
10
|
const os_1 = __importDefault(require("os"));
|
|
10
11
|
const path_1 = __importDefault(require("path"));
|
|
@@ -18,6 +19,7 @@ function inside(root, child) {
|
|
|
18
19
|
/** Worktrees isolate checkout files, not process permissions. Artifacts are never expired with task diagnostics. */
|
|
19
20
|
class DelegatedWorkspaceManager {
|
|
20
21
|
directory;
|
|
22
|
+
static operations = new Map();
|
|
21
23
|
constructor(directory = path_1.default.join(os_1.default.homedir(), '.remote-cli', 'delegation-workspaces')) {
|
|
22
24
|
this.directory = directory;
|
|
23
25
|
}
|
|
@@ -68,6 +70,8 @@ class DelegatedWorkspaceManager {
|
|
|
68
70
|
const record = await this.read('lanes', id);
|
|
69
71
|
if (!record || record.blocked || record.pendingTaskId)
|
|
70
72
|
blocked.add(id);
|
|
73
|
+
else
|
|
74
|
+
this.assertLifecycle(record);
|
|
71
75
|
}
|
|
72
76
|
catch {
|
|
73
77
|
blocked.add(id);
|
|
@@ -76,6 +80,9 @@ class DelegatedWorkspaceManager {
|
|
|
76
80
|
return blocked;
|
|
77
81
|
}
|
|
78
82
|
async prepare(lane, source, input, taskId) {
|
|
83
|
+
return this.withLane(lane.id, () => this.prepareLocked(lane, source, input, taskId));
|
|
84
|
+
}
|
|
85
|
+
async prepareLocked(lane, source, input, taskId) {
|
|
79
86
|
this.assertId(taskId);
|
|
80
87
|
await this.initialize();
|
|
81
88
|
await this.validateSource(source);
|
|
@@ -85,14 +92,22 @@ class DelegatedWorkspaceManager {
|
|
|
85
92
|
}
|
|
86
93
|
const directory = this.worktreeDirectory(source, lane.id);
|
|
87
94
|
const taskRef = `refs/remote-cli/tasks/${taskId}`;
|
|
88
|
-
|
|
95
|
+
let previous = await this.read('lanes', lane.id);
|
|
89
96
|
if (previous) {
|
|
90
|
-
await this.
|
|
91
|
-
if (previous.threadId !== lane.threadId || previous.workspaceGeneration !== lane.workspaceGeneration
|
|
97
|
+
await this.validateWorkspaceRecord(previous);
|
|
98
|
+
if (previous.laneId !== lane.id || previous.threadId !== lane.threadId || previous.workspaceGeneration !== lane.workspaceGeneration
|
|
92
99
|
|| previous.source.cwd !== source.cwd || previous.blocked || previous.pendingTaskId) {
|
|
93
100
|
throw new Error('Worker workspace has unresolved artifacts or a changed identity');
|
|
94
101
|
}
|
|
102
|
+
previous = await this.resolveReclamation(previous);
|
|
103
|
+
}
|
|
104
|
+
if (previous && previous.checkoutState !== 'reclaimed') {
|
|
105
|
+
await this.validateWorkspace(previous);
|
|
95
106
|
await this.assertClean(previous.directory);
|
|
107
|
+
const indexEntries = (await (0, GitCheckpoint_1.runGit)(previous.directory, ['ls-files', '-v', '-z'])).toString('utf8').split('\0').filter(Boolean);
|
|
108
|
+
if (indexEntries.some(entry => !entry.startsWith('H '))) {
|
|
109
|
+
throw new Error('Worker checkout has hidden index state; manual recovery is required');
|
|
110
|
+
}
|
|
96
111
|
// Never reset unknown files, including ignored files that checkout could overwrite.
|
|
97
112
|
await (0, GitCheckpoint_1.runGit)(directory, ['checkout', '--detach', '--no-overwrite-ignore', input.commit]);
|
|
98
113
|
}
|
|
@@ -108,18 +123,30 @@ class DelegatedWorkspaceManager {
|
|
|
108
123
|
await this.safeDirectory(path_1.default.dirname(directory));
|
|
109
124
|
// Anchor the baseline before creating any checkout, even if setup later fails.
|
|
110
125
|
await (0, GitCheckpoint_1.runGit)(source.root, ['update-ref', this.inputRef(taskId), input.commit]);
|
|
111
|
-
await
|
|
126
|
+
await this.withRepository(source, async () => {
|
|
127
|
+
if (await this.registration(source, directory))
|
|
128
|
+
throw new Error('Worker path is still registered; manual recovery is required');
|
|
129
|
+
await (0, GitCheckpoint_1.runGit)(source.root, ['worktree', 'add', '--detach', directory, input.commit]);
|
|
130
|
+
});
|
|
112
131
|
}
|
|
113
132
|
await (0, GitCheckpoint_1.runGit)(source.root, ['update-ref', this.inputRef(taskId), input.commit]);
|
|
114
133
|
await (0, GitCheckpoint_1.runGit)(source.root, ['update-ref', taskRef, input.commit]);
|
|
115
134
|
const workspace = { laneId: lane.id, threadId: lane.threadId,
|
|
116
135
|
workspaceGeneration: lane.workspaceGeneration, source, directory,
|
|
117
|
-
cwd: path_1.default.join(directory, source.relativeCwd), taskId, taskRef, input };
|
|
136
|
+
cwd: path_1.default.join(directory, source.relativeCwd), taskId, taskRef, input, checkoutState: 'present' };
|
|
118
137
|
await this.write('lanes', lane.id, workspace);
|
|
119
138
|
await this.validateWorkspace(workspace);
|
|
120
139
|
return workspace;
|
|
121
140
|
}
|
|
122
141
|
async collect(workspace, successful) {
|
|
142
|
+
return this.withLane(workspace.laneId, () => this.collectLocked(workspace, successful));
|
|
143
|
+
}
|
|
144
|
+
async collectLocked(workspace, successful) {
|
|
145
|
+
const record = await this.read('lanes', workspace.laneId);
|
|
146
|
+
if (!record || record.taskId !== workspace.taskId || record.blocked
|
|
147
|
+
|| record.checkoutState && record.checkoutState !== 'present') {
|
|
148
|
+
throw new Error('Worker workspace task changed before collection');
|
|
149
|
+
}
|
|
123
150
|
await this.validateWorkspace(workspace);
|
|
124
151
|
const checkpoint = await (0, GitCheckpoint_1.captureCheckpoint)(workspace.directory);
|
|
125
152
|
const output = await (0, GitCheckpoint_1.createCheckpointCommit)(workspace.directory, checkpoint.tree, workspace.input.commit, `Remote CLI worker artifact ${workspace.taskId}`);
|
|
@@ -148,20 +175,56 @@ class DelegatedWorkspaceManager {
|
|
|
148
175
|
async integrate(owner, taskId, action, expectedRevision) {
|
|
149
176
|
this.assertId(taskId);
|
|
150
177
|
const artifact = await this.read('artifacts', taskId);
|
|
178
|
+
if (!artifact)
|
|
179
|
+
throw new Error('Worker artifact not found');
|
|
180
|
+
const result = await this.withLane(artifact.laneId, () => this.integrateLocked(owner, taskId, action, expectedRevision));
|
|
181
|
+
if (action === 'apply' || action === 'inspect' && result.deliveredAtHead) {
|
|
182
|
+
await this.reclaim(taskId);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
async integrateLocked(owner, taskId, action, expectedRevision) {
|
|
187
|
+
this.assertId(taskId);
|
|
188
|
+
let artifact = await this.read('artifacts', taskId);
|
|
151
189
|
if (!artifact)
|
|
152
190
|
throw new Error('Worker artifact not found');
|
|
153
191
|
await this.validateArtifact(artifact, owner);
|
|
154
192
|
owner.checkActive?.();
|
|
155
|
-
|
|
156
|
-
const current = await (0, GitCheckpoint_1.captureCheckpoint)(artifact.source.root);
|
|
157
|
-
if (action === 'inspect')
|
|
158
|
-
return { ...view, revision: current.revision,
|
|
159
|
-
deliveryDirectory: artifact.source.cwd, integration: 'Explicit apply or retain is required for pending changes.' };
|
|
160
|
-
if (action !== 'apply' && action !== 'retain')
|
|
193
|
+
if (action !== 'inspect' && action !== 'apply' && action !== 'retain') {
|
|
161
194
|
throw new Error('Artifact action must be inspect, apply, or retain');
|
|
162
|
-
|
|
195
|
+
}
|
|
196
|
+
const current = await (0, GitCheckpoint_1.captureCheckpoint)(artifact.source.root);
|
|
197
|
+
if (action !== 'inspect' && (typeof expectedRevision !== 'string' || expectedRevision !== current.revision)) {
|
|
163
198
|
throw new Error('Delivery workspace changed; inspect the artifact again before integrating');
|
|
164
199
|
}
|
|
200
|
+
if (action !== 'retain') {
|
|
201
|
+
try {
|
|
202
|
+
artifact = await this.recognizeHistoricalDelivery(artifact, owner);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
console.warn('[Delegation] Historical delivery could not be verified; the artifact was preserved');
|
|
206
|
+
// The receipt may already be durable even if clearing its marker failed.
|
|
207
|
+
// Never apply stale pending state over a subsequently reverted delivery.
|
|
208
|
+
try {
|
|
209
|
+
const persisted = await this.read('artifacts', taskId);
|
|
210
|
+
if (!persisted)
|
|
211
|
+
throw new Error('Worker delivery receipt is missing');
|
|
212
|
+
await this.validateArtifact(persisted, owner);
|
|
213
|
+
artifact = persisted;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
if (action === 'apply')
|
|
217
|
+
throw new Error('Worker delivery state is unavailable; inspect again before applying');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
owner.checkActive?.();
|
|
222
|
+
const view = await this.view(artifact);
|
|
223
|
+
if (action === 'inspect')
|
|
224
|
+
return { ...view, revision: current.revision,
|
|
225
|
+
deliveryDirectory: artifact.source.cwd, integration: artifact.deliveredAtHead
|
|
226
|
+
? 'Historically delivered. Later delivery-branch changes do not reopen this artifact.'
|
|
227
|
+
: 'Explicit apply or retain is required for pending changes.' };
|
|
165
228
|
if (artifact.disposition !== 'pending')
|
|
166
229
|
return view;
|
|
167
230
|
if (action === 'retain') {
|
|
@@ -177,15 +240,357 @@ class DelegatedWorkspaceManager {
|
|
|
177
240
|
await this.assertSelectedScope(artifact);
|
|
178
241
|
return this.applyArtifact(artifact, current, owner);
|
|
179
242
|
}
|
|
243
|
+
/** Reconcile only the caller's exited, ready lanes. Admission never removes checkouts. */
|
|
244
|
+
async reconcileHistoricalDelivery(owner, lanes, reclaim = false) {
|
|
245
|
+
const delivered = [];
|
|
246
|
+
for (const lane of lanes) {
|
|
247
|
+
if (lane.state !== 'ready' || lane.threadId !== owner.threadId || lane.workingDirectory !== owner.cwd
|
|
248
|
+
|| lane.workspaceGeneration !== owner.workspaceGeneration)
|
|
249
|
+
continue;
|
|
250
|
+
owner.checkActive?.();
|
|
251
|
+
try {
|
|
252
|
+
const view = await this.withLane(lane.id, async () => {
|
|
253
|
+
const workspace = await this.read('lanes', lane.id);
|
|
254
|
+
if (!workspace || workspace.laneId !== lane.id || workspace.threadId !== owner.threadId
|
|
255
|
+
|| workspace.source.cwd !== owner.cwd || workspace.workspaceGeneration !== owner.workspaceGeneration
|
|
256
|
+
|| workspace.blocked || workspace.pendingTaskId !== workspace.taskId)
|
|
257
|
+
return undefined;
|
|
258
|
+
const artifact = await this.read('artifacts', workspace.taskId);
|
|
259
|
+
if (!artifact || artifact.successful !== true || artifact.disposition === 'retained' || artifact.recovery)
|
|
260
|
+
return undefined;
|
|
261
|
+
const confirmed = await this.recognizeHistoricalDelivery(artifact, owner);
|
|
262
|
+
return confirmed.disposition === 'applied' ? this.view(confirmed) : undefined;
|
|
263
|
+
});
|
|
264
|
+
if (view) {
|
|
265
|
+
delivered.push(view);
|
|
266
|
+
if (reclaim) {
|
|
267
|
+
owner.checkActive?.();
|
|
268
|
+
await this.reclaim(view.taskId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
console.warn('[Delegation] Historical delivery could not be verified; the worker lane was preserved');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return delivered;
|
|
277
|
+
}
|
|
278
|
+
async recognizeHistoricalDelivery(artifact, owner) {
|
|
279
|
+
if (artifact.successful !== true || artifact.disposition === 'retained' || artifact.recovery)
|
|
280
|
+
return artifact;
|
|
281
|
+
const workspace = await this.read('lanes', artifact.laneId);
|
|
282
|
+
if (!workspace || workspace.taskId !== artifact.taskId || workspace.laneId !== artifact.laneId
|
|
283
|
+
|| workspace.threadId !== owner.threadId || workspace.source.cwd !== owner.cwd
|
|
284
|
+
|| workspace.workspaceGeneration !== owner.workspaceGeneration || workspace.blocked)
|
|
285
|
+
return artifact;
|
|
286
|
+
if (artifact.disposition === 'applied' && !workspace.pendingTaskId)
|
|
287
|
+
return artifact;
|
|
288
|
+
if (workspace.pendingTaskId !== artifact.taskId || workspace.checkoutState && workspace.checkoutState !== 'present')
|
|
289
|
+
return artifact;
|
|
290
|
+
await this.validateWorkspaceRecord(workspace);
|
|
291
|
+
await this.validateArtifact(artifact, owner);
|
|
292
|
+
if (workspace.input.commit !== artifact.input.commit || workspace.input.tree !== artifact.input.tree
|
|
293
|
+
|| !GitCheckpoint_1.GIT_OID.test(artifact.originalHead)
|
|
294
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.source.root, ['rev-parse', workspace.taskRef]) !== artifact.output
|
|
295
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.source.root, ['rev-parse', `refs/remote-cli/worker-history/${artifact.taskId}`]) !== artifact.originalHead) {
|
|
296
|
+
throw new Error('Worker delivery references changed');
|
|
297
|
+
}
|
|
298
|
+
owner.checkActive?.();
|
|
299
|
+
if (artifact.disposition === 'applied') {
|
|
300
|
+
// A crash after the durable receipt must not strand its pending-lane marker.
|
|
301
|
+
await this.resolveArtifact(artifact, 'applied', artifact.integratedTree);
|
|
302
|
+
return artifact;
|
|
303
|
+
}
|
|
304
|
+
const head = await (0, GitCheckpoint_1.gitText)(artifact.source.root, ['rev-parse', '--verify', 'HEAD']);
|
|
305
|
+
if (!GitCheckpoint_1.GIT_OID.test(head))
|
|
306
|
+
throw new Error('Delivery HEAD is unavailable');
|
|
307
|
+
try {
|
|
308
|
+
await (0, GitCheckpoint_1.runGit)(artifact.source.root, ['--no-replace-objects', 'merge-base', '--is-ancestor', artifact.output, head]);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error instanceof GitCheckpoint_1.GitCommandError && error.code === 1)
|
|
312
|
+
return artifact;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
if (await (0, GitCheckpoint_1.gitText)(artifact.source.root, ['rev-parse', '--verify', 'HEAD']) !== head) {
|
|
316
|
+
throw new Error('Delivery HEAD changed during historical verification');
|
|
317
|
+
}
|
|
318
|
+
owner.checkActive?.();
|
|
319
|
+
const confirmed = { ...artifact, disposition: 'applied', deliveredAtHead: head };
|
|
320
|
+
// Record historical handoff, not current-byte inclusion or an integration tree.
|
|
321
|
+
await this.resolveArtifact(confirmed, 'applied');
|
|
322
|
+
return confirmed;
|
|
323
|
+
}
|
|
180
324
|
async describe(taskId) {
|
|
181
325
|
const artifact = await this.read('artifacts', taskId);
|
|
182
326
|
return artifact ? this.view(artifact) : undefined;
|
|
183
327
|
}
|
|
328
|
+
/** Closeout needs a fresh, owned receipt, not a cached result or an absent record. */
|
|
329
|
+
async inspectCloseout(owner, taskId) {
|
|
330
|
+
const saved = await this.read('artifacts', taskId);
|
|
331
|
+
if (!saved)
|
|
332
|
+
throw new Error('Worker artifact is unavailable');
|
|
333
|
+
return this.withLane(saved.laneId, async () => {
|
|
334
|
+
const artifact = await this.read('artifacts', taskId);
|
|
335
|
+
if (!artifact || artifact.taskId !== taskId || artifact.laneId !== saved.laneId
|
|
336
|
+
|| typeof artifact.successful !== 'boolean')
|
|
337
|
+
throw new Error('Invalid worker artifact receipt');
|
|
338
|
+
await this.validateArtifact(artifact, owner);
|
|
339
|
+
owner.checkActive?.();
|
|
340
|
+
const workspace = await this.read('lanes', artifact.laneId);
|
|
341
|
+
if (artifact.disposition !== 'retained' && workspace?.taskId === taskId && workspace.blocked) {
|
|
342
|
+
throw new Error('Worker artifact collection requires recovery');
|
|
343
|
+
}
|
|
344
|
+
// Failed workers may have a no-change receipt, but cannot claim an applied patch.
|
|
345
|
+
if (artifact.disposition === 'applied' && !artifact.successful && artifact.tree !== artifact.input.tree) {
|
|
346
|
+
throw new Error('Unsuccessful worker changes have no delivery receipt');
|
|
347
|
+
}
|
|
348
|
+
const view = await this.view(artifact);
|
|
349
|
+
owner.checkActive?.();
|
|
350
|
+
return view;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
184
353
|
/** Removing a conversation does not authorize deleting its files or pending artifacts. */
|
|
185
354
|
async preserveLane(laneId) {
|
|
355
|
+
await this.withLane(laneId, async () => {
|
|
356
|
+
const workspace = await this.read('lanes', laneId);
|
|
357
|
+
if (workspace)
|
|
358
|
+
await this.write('lanes', laneId, { ...workspace, blocked: true });
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/** Called only after worker exit or a durable delivery receipt; never races a cleanup deadline. */
|
|
362
|
+
async reclaim(taskId) {
|
|
363
|
+
try {
|
|
364
|
+
this.assertId(taskId);
|
|
365
|
+
const artifact = await this.read('artifacts', taskId);
|
|
366
|
+
if (!artifact)
|
|
367
|
+
return false;
|
|
368
|
+
return await this.withLane(artifact.laneId, () => this.reclaimLocked(taskId, artifact.laneId));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Physical cleanup is optional. Do not turn a delivered result into a failed task.
|
|
372
|
+
console.warn('[Delegation] Worker checkout retained; automatic reclamation could not be verified');
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async reclaimLocked(taskId, laneId) {
|
|
186
377
|
const workspace = await this.read('lanes', laneId);
|
|
187
|
-
|
|
188
|
-
|
|
378
|
+
const artifact = await this.read('artifacts', taskId);
|
|
379
|
+
if (!workspace || workspace.taskId !== taskId || !artifact || workspace.blocked || workspace.pendingTaskId
|
|
380
|
+
|| artifact.successful !== true || artifact.disposition !== 'applied' || artifact.recovery)
|
|
381
|
+
return false;
|
|
382
|
+
await this.validateReclamationArtifact(workspace, artifact);
|
|
383
|
+
if (workspace.checkoutState === 'reclaimed')
|
|
384
|
+
return false;
|
|
385
|
+
await this.verifyCheckout(workspace, artifact);
|
|
386
|
+
const intent = { ...workspace, checkoutState: 'reclaiming',
|
|
387
|
+
reclamation: { taskId, output: artifact.output, tree: artifact.tree } };
|
|
388
|
+
await this.write('lanes', laneId, intent);
|
|
389
|
+
// Full byte verification holds only the lane guard, not other lanes' metadata admission.
|
|
390
|
+
await this.verifyCheckout(intent, artifact);
|
|
391
|
+
return this.withRepository(workspace.source, async () => {
|
|
392
|
+
const latest = await this.read('lanes', laneId);
|
|
393
|
+
if (!latest || JSON.stringify(latest) !== JSON.stringify(intent))
|
|
394
|
+
throw new Error('Worker reclamation intent changed');
|
|
395
|
+
const registered = await this.registration(workspace.source, workspace.directory);
|
|
396
|
+
if (!registered || registered.locked || registered.head !== artifact.output) {
|
|
397
|
+
throw new Error('Worker registration changed before removal');
|
|
398
|
+
}
|
|
399
|
+
await (0, GitCheckpoint_1.runGit)(workspace.source.root, ['worktree', 'remove', workspace.directory]);
|
|
400
|
+
if (await this.pathExists(workspace.directory) || await this.registration(workspace.source, workspace.directory)) {
|
|
401
|
+
throw new Error('Worker checkout removal was not confirmed');
|
|
402
|
+
}
|
|
403
|
+
await this.write('lanes', laneId, { ...intent, checkoutState: 'reclaimed' });
|
|
404
|
+
return true;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async validateReclamationArtifact(workspace, artifact) {
|
|
408
|
+
await this.validateWorkspaceRecord(workspace);
|
|
409
|
+
await this.validateArtifact(artifact, { threadId: workspace.threadId, cwd: workspace.source.cwd,
|
|
410
|
+
workspaceGeneration: workspace.workspaceGeneration });
|
|
411
|
+
if (artifact.laneId !== workspace.laneId || artifact.taskId !== workspace.taskId
|
|
412
|
+
|| artifact.input.commit !== workspace.input.commit || artifact.input.tree !== workspace.input.tree
|
|
413
|
+
|| artifact.successful !== true || artifact.disposition !== 'applied' || artifact.recovery
|
|
414
|
+
|| workspace.blocked || workspace.pendingTaskId
|
|
415
|
+
|| !GitCheckpoint_1.GIT_OID.test(artifact.originalHead)
|
|
416
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.source.root, ['rev-parse', workspace.taskRef]) !== artifact.output
|
|
417
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.source.root, ['rev-parse', `refs/remote-cli/worker-history/${artifact.taskId}`]) !== artifact.originalHead) {
|
|
418
|
+
throw new Error('Worker checkout has no matching delivery receipt');
|
|
419
|
+
}
|
|
420
|
+
if (workspace.reclamation && (workspace.reclamation.taskId !== artifact.taskId
|
|
421
|
+
|| workspace.reclamation.output !== artifact.output || workspace.reclamation.tree !== artifact.tree)) {
|
|
422
|
+
throw new Error('Worker reclamation receipt changed');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async resolveReclamation(workspace) {
|
|
426
|
+
if (!workspace.checkoutState || workspace.checkoutState === 'present')
|
|
427
|
+
return workspace;
|
|
428
|
+
// The caller holds the lane guard. This resolver does not mutate Git worktree metadata.
|
|
429
|
+
const artifact = await this.read('artifacts', workspace.taskId);
|
|
430
|
+
if (!artifact)
|
|
431
|
+
throw new Error('Worker reclamation artifact is missing');
|
|
432
|
+
await this.validateReclamationArtifact(workspace, artifact);
|
|
433
|
+
const exists = await this.pathExists(workspace.directory);
|
|
434
|
+
const registered = await this.registration(workspace.source, workspace.directory);
|
|
435
|
+
if (!exists && !registered) {
|
|
436
|
+
const reclaimed = { ...workspace, checkoutState: 'reclaimed' };
|
|
437
|
+
await this.write('lanes', workspace.laneId, reclaimed);
|
|
438
|
+
return reclaimed;
|
|
439
|
+
}
|
|
440
|
+
if (workspace.checkoutState === 'reclaiming' && exists && registered) {
|
|
441
|
+
// A fully intact checkout can cancel an interrupted intent. Partial/foreign paths cannot.
|
|
442
|
+
await this.verifyCheckout(workspace, artifact);
|
|
443
|
+
const present = { ...workspace, checkoutState: 'present', reclamation: undefined };
|
|
444
|
+
await this.write('lanes', workspace.laneId, present);
|
|
445
|
+
return present;
|
|
446
|
+
}
|
|
447
|
+
throw new Error('Reclaimed worker path is occupied or partially registered; manual recovery is required');
|
|
448
|
+
}
|
|
449
|
+
async verifyCheckout(workspace, artifact) {
|
|
450
|
+
await this.validateWorkspace(workspace);
|
|
451
|
+
const registered = await this.registration(workspace.source, workspace.directory);
|
|
452
|
+
if (!registered || registered.locked || registered.head !== artifact.output
|
|
453
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.directory, ['branch', '--show-current'])
|
|
454
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.directory, ['rev-parse', 'HEAD']) !== artifact.output
|
|
455
|
+
|| (await (0, GitCheckpoint_1.runGit)(workspace.directory, ['ls-files', '-u', '-z'])).length) {
|
|
456
|
+
throw new Error('Worker checkout is locked, unresolved, or no longer at its saved output');
|
|
457
|
+
}
|
|
458
|
+
await this.assertClean(workspace.directory);
|
|
459
|
+
// Git status and a copied index can hide changed bytes behind assume-unchanged,
|
|
460
|
+
// skip-worktree, or stat-cache flags. Hash actual bytes without filters or index metadata.
|
|
461
|
+
const files = new Map();
|
|
462
|
+
const directories = new Set(['']);
|
|
463
|
+
const tree = await (0, GitCheckpoint_1.runGit)(workspace.source.root, ['ls-tree', '-r', '-z', '--full-tree', artifact.tree]);
|
|
464
|
+
if (!Buffer.from(tree.toString('utf8')).equals(tree))
|
|
465
|
+
throw new Error('Non-UTF-8 checkout paths require manual cleanup');
|
|
466
|
+
for (const entry of tree.toString('utf8').split('\0').filter(Boolean)) {
|
|
467
|
+
const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t(.+)$/s.exec(entry);
|
|
468
|
+
if (!match)
|
|
469
|
+
throw new Error('Unsupported checkout tree entry');
|
|
470
|
+
const [, mode, oid, name] = match;
|
|
471
|
+
if (!inside(workspace.directory, path_1.default.join(workspace.directory, name)) || name.split('/').includes('.git')
|
|
472
|
+
|| files.has(name))
|
|
473
|
+
throw new Error('Invalid checkout tree path');
|
|
474
|
+
files.set(name, { mode, oid });
|
|
475
|
+
let parent = path_1.default.posix.dirname(name);
|
|
476
|
+
while (parent !== '.') {
|
|
477
|
+
directories.add(parent);
|
|
478
|
+
parent = path_1.default.posix.dirname(parent);
|
|
479
|
+
}
|
|
480
|
+
if (files.size > GitCheckpoint_1.CHECKPOINT_LIMITS.files)
|
|
481
|
+
throw new Error('Checkout verification file limit exceeded');
|
|
482
|
+
}
|
|
483
|
+
let bytes = 0;
|
|
484
|
+
let entries = 0;
|
|
485
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
486
|
+
const walk = async (relative) => {
|
|
487
|
+
const absolute = path_1.default.join(workspace.directory, relative);
|
|
488
|
+
if (await promises_1.default.realpath(absolute) !== absolute)
|
|
489
|
+
throw new Error('Checkout directory changed during verification');
|
|
490
|
+
for (const name of await promises_1.default.readdir(absolute)) {
|
|
491
|
+
if (++entries > GitCheckpoint_1.CHECKPOINT_LIMITS.files * 2)
|
|
492
|
+
throw new Error('Checkout verification entry limit exceeded');
|
|
493
|
+
const child = relative ? `${relative}/${name}` : name;
|
|
494
|
+
const file = path_1.default.join(workspace.directory, child);
|
|
495
|
+
const stat = await promises_1.default.lstat(file);
|
|
496
|
+
if (child === '.git' && stat.isFile() && !stat.isSymbolicLink())
|
|
497
|
+
continue;
|
|
498
|
+
if (stat.isDirectory() && directories.has(child)) {
|
|
499
|
+
await walk(child);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const expected = files.get(child);
|
|
503
|
+
if (!expected || (expected.mode === '120000' ? !stat.isSymbolicLink() : !stat.isFile())) {
|
|
504
|
+
throw new Error('Checkout contains unknown, ignored, or unsupported files');
|
|
505
|
+
}
|
|
506
|
+
if (process.platform !== 'win32' && expected.mode !== '120000'
|
|
507
|
+
&& Boolean(stat.mode & 0o111) !== (expected.mode === '100755'))
|
|
508
|
+
throw new Error('Checkout file mode changed');
|
|
509
|
+
bytes += stat.size;
|
|
510
|
+
if (bytes > GitCheckpoint_1.CHECKPOINT_LIMITS.bytes)
|
|
511
|
+
throw new Error('Checkout verification byte limit exceeded');
|
|
512
|
+
const hash = (0, crypto_1.createHash)(expected.oid.length === 64 ? 'sha256' : 'sha1');
|
|
513
|
+
hash.update(`blob ${stat.size}\0`);
|
|
514
|
+
if (stat.isSymbolicLink())
|
|
515
|
+
hash.update(await promises_1.default.readlink(file, { encoding: 'buffer' }));
|
|
516
|
+
else {
|
|
517
|
+
const handle = await promises_1.default.open(file, fs_1.constants.O_RDONLY | (fs_1.constants.O_NOFOLLOW ?? 0));
|
|
518
|
+
try {
|
|
519
|
+
const opened = await handle.stat();
|
|
520
|
+
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size) {
|
|
521
|
+
throw new Error('Checkout file changed during verification');
|
|
522
|
+
}
|
|
523
|
+
let read = 0;
|
|
524
|
+
while (true) {
|
|
525
|
+
const chunk = await handle.read(buffer, 0, buffer.length, null);
|
|
526
|
+
if (!chunk.bytesRead)
|
|
527
|
+
break;
|
|
528
|
+
read += chunk.bytesRead;
|
|
529
|
+
if (read > stat.size)
|
|
530
|
+
throw new Error('Checkout file grew during verification');
|
|
531
|
+
hash.update(buffer.subarray(0, chunk.bytesRead));
|
|
532
|
+
}
|
|
533
|
+
const after = await handle.stat();
|
|
534
|
+
if (read !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
|
|
535
|
+
throw new Error('Checkout file changed during verification');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
await handle.close();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (hash.digest('hex') !== expected.oid)
|
|
543
|
+
throw new Error('Checkout bytes differ from the saved output');
|
|
544
|
+
files.delete(child);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
await walk('');
|
|
548
|
+
if (files.size)
|
|
549
|
+
throw new Error('Saved checkout files are missing');
|
|
550
|
+
}
|
|
551
|
+
async registration(source, directory) {
|
|
552
|
+
let selected = false;
|
|
553
|
+
let result;
|
|
554
|
+
// Git 2.34 has no worktree-list -z option. Decode its quoted paths rather than
|
|
555
|
+
// weakening ownership checks or raising the existing Git requirement.
|
|
556
|
+
const output = await (0, GitCheckpoint_1.runGit)(source.root, ['-c', 'core.quotePath=false', 'worktree', 'list', '--porcelain']);
|
|
557
|
+
if (!Buffer.from(output.toString('utf8')).equals(output))
|
|
558
|
+
throw new Error('Unsupported Git worktree path encoding');
|
|
559
|
+
for (const field of output.toString('utf8').split('\n')) {
|
|
560
|
+
if (field.startsWith('worktree ')) {
|
|
561
|
+
const encoded = field.slice(9);
|
|
562
|
+
let decoded;
|
|
563
|
+
try {
|
|
564
|
+
decoded = encoded.startsWith('"') ? JSON.parse(encoded) : encoded;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
throw new Error('Unsupported Git worktree path encoding; manual recovery is required');
|
|
568
|
+
}
|
|
569
|
+
if (typeof decoded !== 'string')
|
|
570
|
+
throw new Error('Invalid Git worktree path');
|
|
571
|
+
selected = decoded === directory;
|
|
572
|
+
if (selected)
|
|
573
|
+
result = { locked: false };
|
|
574
|
+
}
|
|
575
|
+
else if (selected && result) {
|
|
576
|
+
if (field.startsWith('HEAD '))
|
|
577
|
+
result.head = field.slice(5);
|
|
578
|
+
if (field === 'locked' || field.startsWith('locked '))
|
|
579
|
+
result.locked = true;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
async pathExists(directory) {
|
|
585
|
+
try {
|
|
586
|
+
await promises_1.default.lstat(directory);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (error.code === 'ENOENT')
|
|
591
|
+
return false;
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
189
594
|
}
|
|
190
595
|
async applyArtifact(artifact, current, owner) {
|
|
191
596
|
// Compatible with Git 2.34: merge in an owned scratch checkout, never in the user's worktree.
|
|
@@ -194,7 +599,7 @@ class DelegatedWorkspaceManager {
|
|
|
194
599
|
let created = false;
|
|
195
600
|
let preserve = false;
|
|
196
601
|
try {
|
|
197
|
-
await (0, GitCheckpoint_1.runGit)(artifact.source.root, ['worktree', 'add', '--detach', integrationDirectory, wrapper]);
|
|
602
|
+
await this.withRepository(artifact.source, () => (0, GitCheckpoint_1.runGit)(artifact.source.root, ['worktree', 'add', '--detach', integrationDirectory, wrapper]));
|
|
198
603
|
created = true;
|
|
199
604
|
try {
|
|
200
605
|
await (0, GitCheckpoint_1.runGit)(integrationDirectory, ['-c', 'user.name=Remote CLI', '-c', 'user.email=remote-cli@example.com',
|
|
@@ -244,7 +649,7 @@ class DelegatedWorkspaceManager {
|
|
|
244
649
|
finally {
|
|
245
650
|
if (created && !preserve) {
|
|
246
651
|
// This directory contains only captured integration input and a reproducible merge result.
|
|
247
|
-
await (0, GitCheckpoint_1.runGit)(artifact.source.root, ['worktree', 'remove', '--force', integrationDirectory])
|
|
652
|
+
await this.withRepository(artifact.source, () => (0, GitCheckpoint_1.runGit)(artifact.source.root, ['worktree', 'remove', '--force', integrationDirectory]))
|
|
248
653
|
.catch(() => console.warn('[Delegation] Integration scratch worktree retained for manual cleanup'));
|
|
249
654
|
}
|
|
250
655
|
else if (!created) {
|
|
@@ -267,6 +672,7 @@ class DelegatedWorkspaceManager {
|
|
|
267
672
|
return { taskId: artifact.taskId, disposition: artifact.disposition, successful: artifact.successful,
|
|
268
673
|
baseCommit: artifact.input.commit, outputCommit: artifact.output,
|
|
269
674
|
changedFiles: files.slice(0, 60), filesTruncated: files.length > 60,
|
|
675
|
+
...(artifact.deliveredAtHead ? { deliveredAtHead: artifact.deliveredAtHead } : {}),
|
|
270
676
|
...(artifact.recovery ? { recovery: artifact.recovery } : {}) };
|
|
271
677
|
}
|
|
272
678
|
async assertSelectedScope(artifact) {
|
|
@@ -282,8 +688,10 @@ class DelegatedWorkspaceManager {
|
|
|
282
688
|
|| artifact.threadId !== owner.threadId || artifact.source.cwd !== owner.cwd
|
|
283
689
|
|| artifact.workspaceGeneration !== owner.workspaceGeneration || !GitCheckpoint_1.GIT_OID.test(artifact.output)
|
|
284
690
|
|| !GitCheckpoint_1.GIT_OID.test(artifact.tree) || !GitCheckpoint_1.GIT_OID.test(artifact.input.commit) || !GitCheckpoint_1.GIT_OID.test(artifact.input.tree)
|
|
285
|
-
|| !['pending', 'applied', 'retained'].includes(artifact.disposition)
|
|
691
|
+
|| !['pending', 'applied', 'retained'].includes(artifact.disposition)
|
|
692
|
+
|| artifact.deliveredAtHead !== undefined && (!GitCheckpoint_1.GIT_OID.test(artifact.deliveredAtHead) || artifact.disposition !== 'applied')) {
|
|
286
693
|
throw new Error('Invalid or foreign worker artifact');
|
|
694
|
+
}
|
|
287
695
|
await this.validateSource(artifact.source);
|
|
288
696
|
if (await (0, GitCheckpoint_1.gitText)(artifact.source.root, ['rev-parse', this.outputRef(artifact.taskId)]) !== artifact.output
|
|
289
697
|
|| await (0, GitCheckpoint_1.gitText)(artifact.source.root, ['rev-parse', this.inputRef(artifact.taskId)]) !== artifact.input.commit
|
|
@@ -299,21 +707,37 @@ class DelegatedWorkspaceManager {
|
|
|
299
707
|
throw new Error('Source repository identity changed');
|
|
300
708
|
}
|
|
301
709
|
async validateWorkspace(workspace) {
|
|
710
|
+
await this.validateWorkspaceRecord(workspace);
|
|
711
|
+
if (workspace.checkoutState === 'reclaimed' || !await this.pathExists(workspace.directory)) {
|
|
712
|
+
throw new Error('Owned worker checkout is missing; an explicit reclamation receipt is required');
|
|
713
|
+
}
|
|
714
|
+
if (await promises_1.default.realpath(workspace.directory) !== workspace.directory
|
|
715
|
+
|| await (0, GitCheckpoint_1.gitText)(workspace.directory, ['rev-parse', '--show-toplevel']) !== workspace.directory
|
|
716
|
+
|| await promises_1.default.realpath(path_1.default.resolve(workspace.directory, await (0, GitCheckpoint_1.gitText)(workspace.directory, ['rev-parse', '--git-common-dir']))) !== workspace.source.commonDirectory) {
|
|
717
|
+
throw new Error('Owned worker worktree identity changed');
|
|
718
|
+
}
|
|
719
|
+
if (!inside(workspace.directory, await promises_1.default.realpath(workspace.cwd)))
|
|
720
|
+
throw new Error('Worker directory escapes its worktree');
|
|
721
|
+
}
|
|
722
|
+
assertLifecycle(workspace) {
|
|
723
|
+
const state = workspace.checkoutState ?? 'present';
|
|
724
|
+
if (!['present', 'reclaiming', 'reclaimed'].includes(state)
|
|
725
|
+
|| (state === 'present' ? workspace.reclamation !== undefined : !workspace.reclamation)) {
|
|
726
|
+
throw new Error('Invalid worker checkout lifecycle');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async validateWorkspaceRecord(workspace) {
|
|
302
730
|
this.assertId(workspace.laneId);
|
|
303
731
|
this.assertId(workspace.taskId);
|
|
732
|
+
this.assertLifecycle(workspace);
|
|
304
733
|
if (workspace.taskRef !== `refs/remote-cli/tasks/${workspace.taskId}` || !GitCheckpoint_1.GIT_OID.test(workspace.input.commit)) {
|
|
305
734
|
throw new Error('Owned worker task reference is invalid');
|
|
306
735
|
}
|
|
307
736
|
await this.validateSource(workspace.source);
|
|
308
737
|
if (workspace.directory !== this.worktreeDirectory(workspace.source, workspace.laneId)
|
|
309
|
-
|| workspace.cwd !== path_1.default.join(workspace.directory, workspace.source.relativeCwd)
|
|
310
|
-
|| await promises_1.default.realpath(workspace.directory) !== workspace.directory
|
|
311
|
-
|| await (0, GitCheckpoint_1.gitText)(workspace.directory, ['rev-parse', '--show-toplevel']) !== workspace.directory
|
|
312
|
-
|| await promises_1.default.realpath(path_1.default.resolve(workspace.directory, await (0, GitCheckpoint_1.gitText)(workspace.directory, ['rev-parse', '--git-common-dir']))) !== workspace.source.commonDirectory) {
|
|
738
|
+
|| workspace.cwd !== path_1.default.join(workspace.directory, workspace.source.relativeCwd)) {
|
|
313
739
|
throw new Error('Owned worker worktree identity changed');
|
|
314
740
|
}
|
|
315
|
-
if (!inside(workspace.directory, await promises_1.default.realpath(workspace.cwd)))
|
|
316
|
-
throw new Error('Worker directory escapes its worktree');
|
|
317
741
|
}
|
|
318
742
|
async assertClean(directory) {
|
|
319
743
|
if ((await (0, GitCheckpoint_1.runGit)(directory, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])).length) {
|
|
@@ -329,6 +753,28 @@ class DelegatedWorkspaceManager {
|
|
|
329
753
|
outputRef(taskId) { this.assertId(taskId); return `refs/remote-cli/artifacts/${taskId}`; }
|
|
330
754
|
assertId(id) { if (!UUID.test(id))
|
|
331
755
|
throw new Error('Invalid worker workspace ID'); }
|
|
756
|
+
withLane(laneId, operation) {
|
|
757
|
+
this.assertId(laneId);
|
|
758
|
+
return this.serialize(`lane:${path_1.default.resolve(this.directory)}:${laneId}`, operation);
|
|
759
|
+
}
|
|
760
|
+
withRepository(source, operation) {
|
|
761
|
+
return this.serialize(`repository:${source.commonDirectory}`, operation);
|
|
762
|
+
}
|
|
763
|
+
async serialize(key, operation) {
|
|
764
|
+
const previous = DelegatedWorkspaceManager.operations.get(key) ?? Promise.resolve();
|
|
765
|
+
let release;
|
|
766
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
767
|
+
DelegatedWorkspaceManager.operations.set(key, current);
|
|
768
|
+
await previous;
|
|
769
|
+
try {
|
|
770
|
+
return await operation();
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
release();
|
|
774
|
+
if (DelegatedWorkspaceManager.operations.get(key) === current)
|
|
775
|
+
DelegatedWorkspaceManager.operations.delete(key);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
332
778
|
async initialize() {
|
|
333
779
|
await this.safeDirectory(this.directory);
|
|
334
780
|
await Promise.all(['lanes', 'artifacts', 'trees'].map(name => this.safeDirectory(path_1.default.join(this.directory, name))));
|