@zq-silk/yui 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,11 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
5
5
  import { updateRole } from "../role/role.js";
6
6
  import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
7
+ import { bindTaskWorkspaceIdentity } from "../task/task.js";
7
8
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
8
9
  import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
9
10
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
11
+ import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity } from "./taskWorkspaceIdentity.js";
10
12
  const MAIN_WORKTREE = "main";
11
13
  const LEADER_ROLE = "leader";
12
14
  export class WorkspaceCleanupBlockedError extends Error {
@@ -99,6 +101,15 @@ export class FileTaskWorkspacePreparer {
99
101
  if (existing !== null && existing.owner.type !== "task") {
100
102
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
101
103
  }
104
+ // The durable workspace identity is minted once, only for a Task that has
105
+ // never owned a managed Git workspace. A Task with an existing workspace
106
+ // record predates the identity (legacy) and keeps its refs until the
107
+ // controlled rebuild; a second prepare reuses the persisted identity.
108
+ const workspaceIdentity = task.workspaceIdentity === undefined
109
+ && (existing === null || existing.entries.length === 0)
110
+ ? await this.#mintTaskWorkspaceIdentity(task)
111
+ : undefined;
112
+ const taskSegment = taskWorkspaceRefSegment(workspaceIdentity === undefined ? task : { ...task, workspaceIdentity });
102
113
  const root = this.#taskWorkspaceRoot(task.id);
103
114
  const prepared = [];
104
115
  const defaultProjects = remoteDefaultProjects(this.store, task.id);
@@ -160,7 +171,7 @@ export class FileTaskWorkspacePreparer {
160
171
  const physical = await this.git.ensureWorktree({
161
172
  repositoryPath: project.path,
162
173
  container: this.#projectContainer(project.name),
163
- taskId: task.id,
174
+ taskSegment,
164
175
  roleName: MAIN_WORKTREE,
165
176
  baseRef: previous?.baseCommit ?? baseline.baseRef
166
177
  });
@@ -209,6 +220,12 @@ export class FileTaskWorkspacePreparer {
209
220
  let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
210
221
  ? latest
211
222
  : { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
223
+ if (workspaceIdentity !== undefined) {
224
+ // The identity is persisted only now that every managed ref was
225
+ // created successfully. A concurrent prepare that bound the same
226
+ // identity is a no-op; a different one fails closed.
227
+ persistedTask = bindTaskWorkspaceIdentity(persistedTask, workspaceIdentity, timestamp);
228
+ }
212
229
  if (persistedTask.cwd !== root) {
213
230
  persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
214
231
  }
@@ -236,10 +253,39 @@ export class FileTaskWorkspacePreparer {
236
253
  return { taskId, status: "ready", path: root };
237
254
  }
238
255
  catch (error) {
239
- await this.#discardUnadoptedEntries(task, prepared, MAIN_WORKTREE);
256
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE);
240
257
  throw error;
241
258
  }
242
259
  }
260
+ /**
261
+ * Mint the Task's durable workspace identity with create-not-exists
262
+ * semantics: the candidate main branch must not already exist in any bound
263
+ * Project repository. On conflict (a stale ref from a crashed attempt, or
264
+ * another Home's work) a fresh identity is generated; only an identity whose
265
+ * refs were actually created is ever persisted.
266
+ */
267
+ async #mintTaskWorkspaceIdentity(task) {
268
+ const home = this.store.getHomeIdentity();
269
+ for (let attempt = 0; attempt < 16; attempt += 1) {
270
+ const identity = generateTaskWorkspaceIdentity({
271
+ home,
272
+ taskId: task.id,
273
+ now: this.now()
274
+ });
275
+ const mainBranch = taskMainBranch(taskWorkspaceRefSegmentFromIdentity(identity));
276
+ let conflict = false;
277
+ for (const binding of task.projectBindings) {
278
+ const project = requireProject(this.store, binding.projectId);
279
+ if (await this.git.refExists(project.path, mainBranch)) {
280
+ conflict = true;
281
+ break;
282
+ }
283
+ }
284
+ if (!conflict)
285
+ return identity;
286
+ }
287
+ throw new Error(`Could not mint a unique Task workspace identity for ${task.id}.`);
288
+ }
243
289
  async snapshotCandidateWorkspace(workspace) {
244
290
  if (workspace.owner.type === "review-round") {
245
291
  throw new Error("ReviewRound workspace cannot become a WorkItem Candidate source.");
@@ -331,6 +377,9 @@ export class FileTaskWorkspacePreparer {
331
377
  const task = requireTask(this.store, item.taskId);
332
378
  assertWorkItemWorkspaceEligible(this.store, task, item);
333
379
  await this.prepareTaskWorkspace(task.id);
380
+ // prepareTaskWorkspace may have just minted and persisted the workspace
381
+ // identity; derive the segment from the persisted Task.
382
+ const taskSegment = this.#taskSegment(requireTask(this.store, task.id));
334
383
  const main = this.store.getTaskWorkspace(task.id);
335
384
  if (main === null || main.owner.type !== "task") {
336
385
  throw new Error(`Task main workspace is not ready: ${task.id}.`);
@@ -378,7 +427,7 @@ export class FileTaskWorkspacePreparer {
378
427
  const physical = await this.git.ensureWorktree({
379
428
  repositoryPath: project.path,
380
429
  container: this.#projectContainer(project.name),
381
- taskId: task.id,
430
+ taskSegment,
382
431
  roleName: item.id,
383
432
  baseRef
384
433
  });
@@ -467,7 +516,7 @@ export class FileTaskWorkspacePreparer {
467
516
  });
468
517
  }
469
518
  catch (error) {
470
- await this.#discardUnadoptedEntries(task, prepared, item.id);
519
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, item.id);
471
520
  throw error;
472
521
  }
473
522
  }
@@ -485,6 +534,7 @@ export class FileTaskWorkspacePreparer {
485
534
  if (source === null) {
486
535
  throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
487
536
  }
537
+ const taskSegment = this.#taskSegment(task);
488
538
  const owner = lineage.purpose === "execution"
489
539
  ? {
490
540
  type: "execution-lane",
@@ -513,7 +563,7 @@ export class FileTaskWorkspacePreparer {
513
563
  const physical = await this.git.ensureWorktree({
514
564
  repositoryPath: project.path,
515
565
  container: this.#projectContainer(project.name),
516
- taskId,
566
+ taskSegment,
517
567
  roleName: managedWorktreeName(owner),
518
568
  baseRef: entry.baseCommit
519
569
  });
@@ -541,7 +591,7 @@ export class FileTaskWorkspacePreparer {
541
591
  const physical = await this.git.ensureWorktree({
542
592
  repositoryPath: project.path,
543
593
  container: this.#projectContainer(project.name),
544
- taskId,
594
+ taskSegment,
545
595
  roleName: managedWorktreeName(owner),
546
596
  baseRef: sourceEntry.baseCommit
547
597
  });
@@ -579,7 +629,7 @@ export class FileTaskWorkspacePreparer {
579
629
  return workspace;
580
630
  }
581
631
  catch (error) {
582
- await this.#discardUnadoptedEntries(task, prepared, managedWorktreeName(owner), true);
632
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, managedWorktreeName(owner), true);
583
633
  throw error;
584
634
  }
585
635
  }
@@ -780,7 +830,7 @@ export class FileTaskWorkspacePreparer {
780
830
  && owner.executionLaneId === executionLaneId));
781
831
  if (workspace === undefined)
782
832
  return "missing";
783
- const state = await this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
833
+ const state = await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
784
834
  if (state === "dirty")
785
835
  return "dirty";
786
836
  let removed = false;
@@ -789,7 +839,7 @@ export class FileTaskWorkspacePreparer {
789
839
  const result = await this.git.removeWorktree({
790
840
  repositoryPath: project.path,
791
841
  container: this.#projectContainer(project.name),
792
- taskId,
842
+ taskSegment: this.#taskSegment(task),
793
843
  roleName: managedWorktreeName(workspace.owner),
794
844
  deleteBranch: true
795
845
  });
@@ -808,7 +858,9 @@ export class FileTaskWorkspacePreparer {
808
858
  }
809
859
  if (this.store.getManagedWorkspace(workspace.owner) !== null)
810
860
  return "missing";
811
- const state = await this.#inspectEntries(workspace.owner.taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
861
+ const task = requireTask(this.store, workspace.owner.taskId);
862
+ const taskSegment = this.#taskSegment(task);
863
+ const state = await this.#inspectEntries(taskSegment, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
812
864
  if (state === "dirty")
813
865
  return "dirty";
814
866
  let removed = false;
@@ -817,7 +869,7 @@ export class FileTaskWorkspacePreparer {
817
869
  const result = await this.git.removeWorktree({
818
870
  repositoryPath: project.path,
819
871
  container: this.#projectContainer(project.name),
820
- taskId: workspace.owner.taskId,
872
+ taskSegment,
821
873
  roleName: managedWorktreeName(workspace.owner),
822
874
  deleteBranch: true
823
875
  });
@@ -878,6 +930,7 @@ export class FileTaskWorkspacePreparer {
878
930
  * never used as a ChangeSet capture source. */
879
931
  async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
880
932
  const task = requireTask(this.store, taskId);
933
+ const taskSegment = this.#taskSegment(task);
881
934
  const round = this.store.getReviewRound(task.id, reviewRoundId);
882
935
  if (round === null)
883
936
  throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
@@ -915,7 +968,7 @@ export class FileTaskWorkspacePreparer {
915
968
  throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
916
969
  }
917
970
  const project = requireProject(this.store, binding.projectId);
918
- const identity = worktreeIdentity(task.id, round.id);
971
+ const identity = worktreeIdentity(taskSegment, round.id);
919
972
  return {
920
973
  projectId: binding.projectId,
921
974
  directory: binding.directory,
@@ -967,7 +1020,7 @@ export class FileTaskWorkspacePreparer {
967
1020
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
968
1021
  }
969
1022
  const project = requireProject(this.store, entry.projectId);
970
- const identity = worktreeIdentity(task.id, round.id);
1023
+ const identity = worktreeIdentity(taskSegment, round.id);
971
1024
  const expectedPath = join(this.#projectContainer(project.name), identity.directory);
972
1025
  if (entry.path !== expectedPath || entry.branch !== identity.branch) {
973
1026
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
@@ -1053,7 +1106,7 @@ export class FileTaskWorkspacePreparer {
1053
1106
  const physical = await this.git.ensureWorktree({
1054
1107
  repositoryPath: project.path,
1055
1108
  container: this.#projectContainer(project.name),
1056
- taskId: task.id,
1109
+ taskSegment,
1057
1110
  roleName: round.id,
1058
1111
  baseRef: source.baseCommit
1059
1112
  });
@@ -1155,11 +1208,12 @@ export class FileTaskWorkspacePreparer {
1155
1208
  });
1156
1209
  }
1157
1210
  catch (error) {
1158
- await this.#discardUnadoptedEntries(task, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1211
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1159
1212
  throw error;
1160
1213
  }
1161
1214
  }
1162
1215
  async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
1216
+ const task = requireTask(this.store, taskId);
1163
1217
  const round = this.store.getReviewRound(taskId, reviewRoundId);
1164
1218
  if (round === null)
1165
1219
  throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
@@ -1170,7 +1224,7 @@ export class FileTaskWorkspacePreparer {
1170
1224
  if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, workspace)) {
1171
1225
  throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1172
1226
  }
1173
- return this.#inspectEntries(taskId, round.id, workspace.entries);
1227
+ return this.#inspectEntries(this.#taskSegment(task), round.id, workspace.entries);
1174
1228
  }
1175
1229
  async snapshotReviewRoundResult(taskId, reviewRoundId) {
1176
1230
  const round = this.store.getReviewRound(taskId, reviewRoundId);
@@ -1241,13 +1295,13 @@ export class FileTaskWorkspacePreparer {
1241
1295
  return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
1242
1296
  }
1243
1297
  async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
1244
- requireTask(this.store, taskId);
1298
+ const task = requireTask(this.store, taskId);
1245
1299
  const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
1246
1300
  && owner.executionGroupId === executionGroupId
1247
1301
  && owner.executionLaneId === executionLaneId));
1248
1302
  if (workspace === undefined)
1249
1303
  return "missing";
1250
- return this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1304
+ return this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1251
1305
  }
1252
1306
  async cleanupReviewRoundWorkspace(taskId, reviewRoundId) {
1253
1307
  const task = requireTask(this.store, taskId);
@@ -1271,7 +1325,7 @@ export class FileTaskWorkspacePreparer {
1271
1325
  for (const lane of round.executionGroup?.lanes ?? []) {
1272
1326
  assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
1273
1327
  }
1274
- if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
1328
+ if (await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
1275
1329
  return "dirty";
1276
1330
  let removed = false;
1277
1331
  for (const entry of workspace.entries) {
@@ -1279,7 +1333,7 @@ export class FileTaskWorkspacePreparer {
1279
1333
  const result = await this.git.removeWorktree({
1280
1334
  repositoryPath: project.path,
1281
1335
  container: this.#projectContainer(project.name),
1282
- taskId: task.id,
1336
+ taskSegment: this.#taskSegment(task),
1283
1337
  roleName: managedWorktreeName(workspace.owner),
1284
1338
  deleteBranch: true
1285
1339
  });
@@ -1336,7 +1390,7 @@ export class FileTaskWorkspacePreparer {
1336
1390
  assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
1337
1391
  }
1338
1392
  const writable = workspace.entries.filter(({ access }) => access === "write");
1339
- if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), writable) === "dirty")
1393
+ if (await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), writable) === "dirty")
1340
1394
  return "dirty";
1341
1395
  let removed = false;
1342
1396
  for (const entry of writable) {
@@ -1344,7 +1398,7 @@ export class FileTaskWorkspacePreparer {
1344
1398
  const result = await this.git.removeWorktree({
1345
1399
  repositoryPath: project.path,
1346
1400
  container: this.#projectContainer(project.name),
1347
- taskId: task.id,
1401
+ taskSegment: this.#taskSegment(task),
1348
1402
  roleName: managedWorktreeName(workspace.owner),
1349
1403
  deleteBranch: true
1350
1404
  });
@@ -1375,7 +1429,7 @@ export class FileTaskWorkspacePreparer {
1375
1429
  if (main.owner.type !== "task") {
1376
1430
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
1377
1431
  }
1378
- return this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries);
1432
+ return this.#inspectEntries(this.#taskSegment(task), MAIN_WORKTREE, main.entries);
1379
1433
  }
1380
1434
  async cleanupTaskForArchive(taskId) {
1381
1435
  const task = requireTask(this.store, taskId);
@@ -1393,7 +1447,7 @@ export class FileTaskWorkspacePreparer {
1393
1447
  const main = this.store.getTaskWorkspace(task.id);
1394
1448
  if (main !== null) {
1395
1449
  assertTaskArchiveState(requireTask(this.store, task.id), task);
1396
- if (await this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries) === "dirty") {
1450
+ if (await this.#inspectEntries(this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
1397
1451
  return {
1398
1452
  taskId,
1399
1453
  status: "retained-dirty",
@@ -1406,7 +1460,7 @@ export class FileTaskWorkspacePreparer {
1406
1460
  const result = await this.git.removeWorktree({
1407
1461
  repositoryPath: project.path,
1408
1462
  container: this.#projectContainer(project.name),
1409
- taskId: task.id,
1463
+ taskSegment: this.#taskSegment(task),
1410
1464
  roleName: MAIN_WORKTREE
1411
1465
  });
1412
1466
  if (result === "dirty") {
@@ -1421,7 +1475,203 @@ export class FileTaskWorkspacePreparer {
1421
1475
  this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
1422
1476
  return { taskId, status: "removed" };
1423
1477
  }
1424
- async #inspectEntries(taskId, roleName, entries) {
1478
+ /**
1479
+ * Rebuild an eligible legacy Task's managed Git workspace under a fresh
1480
+ * workspace identity. Legacy refs are archived (never deleted outright),
1481
+ * the new main worktrees start from verified remote SHAs, and the Task
1482
+ * record switches only after every Git side effect succeeded, so a crash
1483
+ * at any point leaves the old layout usable and the command resumable.
1484
+ *
1485
+ * A Task that already carries an identity takes the resume path: only the
1486
+ * pending legacy archive and old-worktree removal run.
1487
+ */
1488
+ async rebuildTaskWorkspace(taskId) {
1489
+ const task = requireTask(this.store, taskId);
1490
+ if (!["draft", "active"].includes(task.status)) {
1491
+ throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
1492
+ }
1493
+ if (task.workspaceIdentity !== undefined) {
1494
+ const current = this.store.getTaskWorkspace(task.id);
1495
+ if (current !== null && current.owner.type === "task") {
1496
+ await ensureWorkspaceView(current.root, current.entries);
1497
+ }
1498
+ // Remove the legacy worktrees before archiving their refs: a worktree
1499
+ // whose checked-out branch was just deleted reports an unborn-branch
1500
+ // ("dirty") status and can no longer be removed cleanly.
1501
+ await this.#removeLegacyWorktrees(task, this.store.listProjects().map((project) => project.id));
1502
+ const archived = await this.#archiveLegacyRefs(task);
1503
+ return { task: requireTask(this.store, task.id), archived, resumed: true };
1504
+ }
1505
+ assertTaskHasNoEvidence(this.store, task.id);
1506
+ const existing = this.store.getTaskWorkspace(task.id);
1507
+ if (existing !== null && existing.owner.type !== "task") {
1508
+ throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
1509
+ }
1510
+ if (existing !== null
1511
+ && await this.#inspectEntries(task.id, MAIN_WORKTREE, existing.entries) === "dirty") {
1512
+ throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
1513
+ }
1514
+ // Resolve verified remote SHAs before any Git side effect, mirroring the
1515
+ // first-prepare pinning: a remote default Project is fetched and its
1516
+ // exact advertised SHA is pinned; a local or explicit ref is validated.
1517
+ const defaultProjects = remoteDefaultProjects(this.store, task.id);
1518
+ const pins = new Map();
1519
+ for (const binding of task.projectBindings) {
1520
+ const project = requireProject(this.store, binding.projectId);
1521
+ const useRemoteDefault = defaultProjects.has(project.id)
1522
+ && project.remoteUrl !== undefined
1523
+ && !looksLikeCommit(binding.baseRef);
1524
+ if (useRemoteDefault) {
1525
+ const resolver = this.git.resolveRemoteBaseline;
1526
+ if (typeof resolver !== "function") {
1527
+ throw new Error(`Git workspace cannot resolve the remote baseline for Project: ${project.id}.`);
1528
+ }
1529
+ const remote = await resolver.call(this.git, {
1530
+ repositoryPath: project.path,
1531
+ remoteUrl: project.remoteUrl,
1532
+ // The binding captured the configured development ref at Task
1533
+ // creation; use that snapshot even if the Project catalog changed.
1534
+ developmentRef: binding.baseRef
1535
+ });
1536
+ pins.set(project.id, remote.commit);
1537
+ }
1538
+ else {
1539
+ await this.git.inspect(project.path, binding.baseRef);
1540
+ }
1541
+ }
1542
+ const identity = await this.#mintTaskWorkspaceIdentity(task);
1543
+ const taskSegment = taskWorkspaceRefSegmentFromIdentity(identity);
1544
+ const root = this.#taskWorkspaceRoot(task.id);
1545
+ const prepared = [];
1546
+ try {
1547
+ for (const binding of task.projectBindings) {
1548
+ const project = requireProject(this.store, binding.projectId);
1549
+ const baseRef = pins.get(project.id) ?? binding.baseRef;
1550
+ const physical = await this.git.ensureWorktree({
1551
+ repositoryPath: project.path,
1552
+ container: this.#projectContainer(project.name),
1553
+ taskSegment,
1554
+ roleName: MAIN_WORKTREE,
1555
+ baseRef
1556
+ });
1557
+ if (pins.has(project.id) && physical.baseCommit !== pins.get(project.id)) {
1558
+ throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
1559
+ }
1560
+ prepared.push({
1561
+ project,
1562
+ entry: {
1563
+ projectId: project.id,
1564
+ directory: binding.directory,
1565
+ access: "write",
1566
+ path: physical.path,
1567
+ branch: physical.branch,
1568
+ baseRef,
1569
+ baseCommit: physical.baseCommit
1570
+ }
1571
+ });
1572
+ }
1573
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
1574
+ const workspace = createManagedWorkspace({
1575
+ owner: { type: "task", taskId: task.id },
1576
+ root,
1577
+ entries: prepared.map(({ entry }) => entry)
1578
+ }, this.now());
1579
+ // Switch the durable record only now that every new ref/worktree exists.
1580
+ this.store.transaction((tx) => {
1581
+ const latest = requireTask(tx, task.id);
1582
+ if (!["draft", "active"].includes(latest.status)
1583
+ || latest.workspaceIdentity !== undefined
1584
+ || !isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
1585
+ throw new Error(`Task changed while rebuilding its workspace: ${task.id}.`);
1586
+ }
1587
+ assertTaskHasNoEvidence(tx, task.id);
1588
+ const current = tx.getTaskWorkspace(task.id);
1589
+ if (current === null
1590
+ ? existing !== null
1591
+ : existing === null || !sameManagedWorkspace(current, existing)) {
1592
+ throw new Error(`Task workspace changed while rebuilding: ${task.id}.`);
1593
+ }
1594
+ const pinnedBindings = latest.projectBindings.map((binding) => {
1595
+ const pinned = pins.get(binding.projectId);
1596
+ return pinned !== undefined ? { ...binding, baseRef: pinned } : binding;
1597
+ });
1598
+ const timestamp = this.now();
1599
+ let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
1600
+ ? latest
1601
+ : { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
1602
+ persistedTask = bindTaskWorkspaceIdentity(persistedTask, identity, timestamp);
1603
+ if (persistedTask.cwd !== root) {
1604
+ persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
1605
+ }
1606
+ if (!isDeepStrictEqual(persistedTask, latest)) {
1607
+ tx.saveTask(persistedTask);
1608
+ }
1609
+ if (existing !== null)
1610
+ tx.removeManagedWorkspace(existing.owner);
1611
+ tx.saveManagedWorkspace(workspace);
1612
+ });
1613
+ // Record switched: retire the legacy layout. The worktrees leave first
1614
+ // (their branches are retained), then the refs are archived and
1615
+ // deleted; each step is resumable.
1616
+ await this.#removeLegacyWorktrees(task, existing === null
1617
+ ? this.store.listProjects().map((project) => project.id)
1618
+ : existing.entries.map(({ projectId }) => projectId));
1619
+ const archived = await this.#archiveLegacyRefs(task, existing === null ? undefined : [...new Set(existing.entries.map(({ projectId }) => projectId))]);
1620
+ return { task: requireTask(this.store, task.id), archived, resumed: false };
1621
+ }
1622
+ catch (error) {
1623
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
1624
+ throw error;
1625
+ }
1626
+ }
1627
+ /**
1628
+ * List legacy (pre-identity) Task refs across the Home's Project
1629
+ * repositories, optionally restricted to one Task. Identity-bearing
1630
+ * branches (`yui/task-N-<8hex>/...`) are never legacy.
1631
+ */
1632
+ async listLegacyTaskRefs(taskId) {
1633
+ const found = [];
1634
+ for (const project of this.store.listProjects()) {
1635
+ const refs = await this.git.listRefs(project.path, "refs/heads/yui/");
1636
+ for (const ref of refs) {
1637
+ if (!isLegacyTaskRef(ref))
1638
+ continue;
1639
+ const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
1640
+ if (taskId !== undefined && ownerTaskId !== taskId)
1641
+ continue;
1642
+ found.push({ projectId: project.id, taskId: ownerTaskId, ref });
1643
+ }
1644
+ }
1645
+ return found;
1646
+ }
1647
+ /**
1648
+ * Archive legacy Task refs into the Home-scoped archive namespace
1649
+ * (`refs/yui/archive/<homeId>/heads/...`). Refs owned by a draft/active
1650
+ * Task are refused: their worktrees may still be live. Terminal and
1651
+ * unknown-owner refs are archived; an already-archived or missing ref is
1652
+ * simply absent on retry.
1653
+ */
1654
+ async archiveLegacyTaskRefs(taskId) {
1655
+ const home = this.store.getHomeIdentity();
1656
+ const refused = [];
1657
+ const archived = [];
1658
+ for (const entry of await this.listLegacyTaskRefs(taskId)) {
1659
+ const owner = this.store.getTask(entry.taskId);
1660
+ if (owner !== null && ["draft", "active"].includes(owner.status)) {
1661
+ refused.push(`${entry.projectId}:${entry.ref}`);
1662
+ continue;
1663
+ }
1664
+ const project = requireProject(this.store, entry.projectId);
1665
+ await this.git.archiveRef({
1666
+ repositoryPath: project.path,
1667
+ sourceRef: entry.ref,
1668
+ archiveRef: taskArchiveRef(home.homeId, entry.ref)
1669
+ });
1670
+ archived.push(`${entry.projectId}:${entry.ref}`);
1671
+ }
1672
+ return { archived, refused };
1673
+ }
1674
+ async #inspectEntries(taskSegment, roleName, entries) {
1425
1675
  if (entries.length === 0)
1426
1676
  return "missing";
1427
1677
  let found = false;
@@ -1430,7 +1680,7 @@ export class FileTaskWorkspacePreparer {
1430
1680
  const state = await this.git.inspectWorktree({
1431
1681
  repositoryPath: project.path,
1432
1682
  container: this.#projectContainer(project.name),
1433
- taskId,
1683
+ taskSegment,
1434
1684
  roleName
1435
1685
  });
1436
1686
  if (state === "dirty")
@@ -1463,6 +1713,15 @@ export class FileTaskWorkspacePreparer {
1463
1713
  #projectContainer(projectName) {
1464
1714
  return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
1465
1715
  }
1716
+ /**
1717
+ * The Task workspace ref segment for Git worktree derivation: the
1718
+ * token-bearing segment for a Task with a persisted identity, its bare id
1719
+ * for a pre-identity record. Every managed ref/path for one Task resolves
1720
+ * through this single helper.
1721
+ */
1722
+ #taskSegment(task) {
1723
+ return taskWorkspaceRefSegment(task);
1724
+ }
1466
1725
  #taskWorkspaceRoot(taskId) {
1467
1726
  return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "main");
1468
1727
  }
@@ -1478,13 +1737,13 @@ export class FileTaskWorkspacePreparer {
1478
1737
  #fallbackWorkspace() {
1479
1738
  return this.store.getConfig().defaultWorkspace ?? process.cwd();
1480
1739
  }
1481
- async #discardUnadoptedEntries(task, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
1740
+ async #discardUnadoptedEntries(task, taskSegment, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
1482
1741
  .flatMap((workspace) => workspace.entries.map(({ path }) => path)))) {
1483
1742
  for (const { project, entry } of prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path))) {
1484
1743
  const removal = await this.git.removeWorktree({
1485
1744
  repositoryPath: project.path,
1486
1745
  container: this.#projectContainer(project.name),
1487
- taskId: task.id,
1746
+ taskSegment,
1488
1747
  roleName,
1489
1748
  ...(deleteBranch ? { deleteBranch: true } : {})
1490
1749
  });
@@ -1493,6 +1752,57 @@ export class FileTaskWorkspacePreparer {
1493
1752
  }
1494
1753
  }
1495
1754
  }
1755
+ /**
1756
+ * Archive one Task's legacy refs (`refs/heads/yui/<taskId>/...`) into the
1757
+ * Home-scoped archive namespace across the given Project repositories. When
1758
+ * no Project list is given, every Project in the Home is scanned, so the
1759
+ * resume path also finds refs in Projects that left the Task binding.
1760
+ */
1761
+ async #archiveLegacyRefs(task, projectIds) {
1762
+ const home = this.store.getHomeIdentity();
1763
+ const projects = projectIds === undefined
1764
+ ? this.store.listProjects()
1765
+ : projectIds.map((id) => requireProject(this.store, id));
1766
+ const seen = new Set();
1767
+ const archived = [];
1768
+ for (const project of projects) {
1769
+ const refs = await this.git.listRefs(project.path, `refs/heads/yui/${task.id}/`);
1770
+ for (const ref of refs) {
1771
+ const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
1772
+ if (ownerTaskId !== task.id || !isLegacyTaskRef(ref))
1773
+ continue;
1774
+ const key = `${project.id}:${ref}`;
1775
+ if (seen.has(key))
1776
+ continue;
1777
+ seen.add(key);
1778
+ await this.git.archiveRef({
1779
+ repositoryPath: project.path,
1780
+ sourceRef: ref,
1781
+ archiveRef: taskArchiveRef(home.homeId, ref)
1782
+ });
1783
+ archived.push(key);
1784
+ }
1785
+ }
1786
+ return archived;
1787
+ }
1788
+ /**
1789
+ * Remove the legacy worktrees of a Task (the bare `<taskId>/main` layout).
1790
+ * A missing worktree is expected on retry and ignored; a dirty one blocks.
1791
+ */
1792
+ async #removeLegacyWorktrees(task, projectIds) {
1793
+ for (const projectId of projectIds) {
1794
+ const project = requireProject(this.store, projectId);
1795
+ const removal = await this.git.removeWorktree({
1796
+ repositoryPath: project.path,
1797
+ container: this.#projectContainer(project.name),
1798
+ taskSegment: task.id,
1799
+ roleName: MAIN_WORKTREE
1800
+ });
1801
+ if (removal === "dirty") {
1802
+ throw new Error(`Legacy Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
1803
+ }
1804
+ }
1805
+ }
1496
1806
  #recordWorkspaceRemoval(task, workspace, fallback, workItem) {
1497
1807
  this.store.transaction((tx) => {
1498
1808
  if (workspace.owner.type === "task") {
@@ -1555,6 +1865,19 @@ function assertTaskArchiveState(current, expected) {
1555
1865
  throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
1556
1866
  }
1557
1867
  }
1868
+ /**
1869
+ * A controlled rebuild is only safe for a Task that owns no delivery
1870
+ * evidence: a Run, WorkItem, ChangeSet, or Integration would pin the old
1871
+ * refs and worktrees as historical proof.
1872
+ */
1873
+ function assertTaskHasNoEvidence(store, taskId) {
1874
+ if (store.listAgentRuns(taskId).length > 0
1875
+ || store.listWorkItems(taskId).length > 0
1876
+ || store.listChangeSets(taskId).length > 0
1877
+ || store.listIntegrationAttempts(taskId).length > 0) {
1878
+ throw new Error(`Task has Run, Work item, Change set, or Integration evidence and cannot be rebuilt in place: ${taskId}.`);
1879
+ }
1880
+ }
1558
1881
  function requireProject(store, projectId) {
1559
1882
  const project = store.getProject(projectId);
1560
1883
  if (project === null)