engineering-memory 1.11.4 → 1.11.6

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.
@@ -1,3 +1,5 @@
1
+ import { validWorktreePolicy } from './worktree-policy.js';
2
+ import { resolveTaskBase } from './branch-preferences.js';
1
3
  import { randomUUID } from 'node:crypto';
2
4
  import { lstat, readFile } from 'node:fs/promises';
3
5
  import { dirname, join } from 'node:path';
@@ -29,6 +31,7 @@ export const validationIds = [
29
31
  ];
30
32
  export class BridgeService {
31
33
  dependencies;
34
+ poolAllocations = new Map();
32
35
  taskQueues = new Map();
33
36
  deferDeliveries;
34
37
  backgroundDeliveries = new Set();
@@ -39,6 +42,9 @@ export class BridgeService {
39
42
  this.dependencies = dependencies;
40
43
  this.deferDeliveries = dependencies.deferDeliveries ?? true;
41
44
  }
45
+ managesWorktrees() {
46
+ return Boolean(this.dependencies.worktreePool);
47
+ }
42
48
  async questionnaireAsk(input, previousDefinitions = []) {
43
49
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
44
50
  const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
@@ -198,6 +204,17 @@ export class BridgeService {
198
204
  ? null
199
205
  : await this.requireBranchDecision(repository, input.externalTaskId);
200
206
  const branch = decision?.branch;
207
+ const poolAllocation = decision && this.dependencies.worktreePool
208
+ ? await this.dependencies.worktreePool.find(projectId, input.externalTaskId, repository.repoRoot)
209
+ : null;
210
+ if (poolAllocation) {
211
+ if (poolAllocation.repoRoot !== repository.repoRoot)
212
+ throw refuse('Bootstrap in the allocated repoRoot.', 'session.resume');
213
+ await this.dependencies.worktreePool.assertOwnership(projectId, repository.repoRoot, poolAllocation.generation);
214
+ if (!poolAllocation.taskId && (source?.sourceCommit ?? null) !== poolAllocation.baseCommit)
215
+ throw refuse('The chosen task base changed before bootstrap. Preserve the intervening commit and use task.branch with a new externalTaskId to choose it explicitly, or resume this allocation only when its recorded base is checked out. No Git state was reset.', 'task.branch');
216
+ this.poolAllocations.set(repository.repoRoot, poolAllocation);
217
+ }
201
218
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
202
219
  const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
203
220
  method: 'POST',
@@ -241,6 +258,11 @@ export class BridgeService {
241
258
  await this.dependencies.repositories.git
242
259
  .branchStore(repository.repoRoot)
243
260
  .bind(projectId, input.externalTaskId, task.id);
261
+ if (poolAllocation) {
262
+ await this.dependencies.worktreePool.bind(projectId, repository.repoRoot, poolAllocation.generation, task.id);
263
+ poolAllocation.taskId = task.id;
264
+ this.poolAllocations.set(repository.repoRoot, poolAllocation);
265
+ }
244
266
  const pointer = {
245
267
  repoFingerprint: repository.repoFingerprint,
246
268
  projectId,
@@ -248,6 +270,7 @@ export class BridgeService {
248
270
  taskSlug: input.externalTaskId,
249
271
  source: sourceFromContext(task.sourceContext),
250
272
  worktreeId: sha256(repository.repoRoot),
273
+ ...(poolAllocation ? { worktreeGeneration: poolAllocation.generation } : {}),
251
274
  ...(decision
252
275
  ? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
253
276
  : {}),
@@ -365,10 +388,57 @@ export class BridgeService {
365
388
  (repository.projectId && repository.projectId !== projectId)) {
366
389
  throw refuse('The live task belongs to a different project than the one this repository is bound to.', 'project.resolve');
367
390
  }
391
+ if (pointer?.worktreeGeneration && this.dependencies.worktreePool) {
392
+ const pool = this.dependencies.worktreePool;
393
+ const previous = await pool.find(projectId, taskSlug, repository.repoRoot);
394
+ if (previous && previous.repoRoot !== repository.repoRoot)
395
+ return asJsonValue({
396
+ worktreeResumeRequired: true,
397
+ repoRoot: previous.repoRoot,
398
+ taskSlug,
399
+ sessionId,
400
+ nextAction: 'Call session.resume from this returned repoRoot and use it for every subsequent command.',
401
+ });
402
+ const sourceCommit = previous?.baseCommit ?? pointer.source?.sourceCommit;
403
+ if ((!sourceCommit && !(previous && previous.baseCommit === null && !previous.managed)) ||
404
+ pointer.branch === undefined)
405
+ throw refuse('The task branch and base must be restored before resuming its worktree.', 'worktree.reconcile');
406
+ const allocation = await pool.allocate({
407
+ projectId,
408
+ projectName: projectId,
409
+ repoFingerprint: repository.repoFingerprint,
410
+ repoRoot: repository.repoRoot,
411
+ externalTaskId: taskSlug,
412
+ branch: pointer.branch,
413
+ baseCommit: sourceCommit ?? null,
414
+ keepCurrent: previous ? !previous.managed : false,
415
+ resume: true,
416
+ }, await this.poolPolicy());
417
+ await pool.bind(projectId, allocation.repoRoot, allocation.generation, pointer.taskId);
418
+ allocation.taskId = pointer.taskId;
419
+ this.poolAllocations.set(allocation.repoRoot, allocation);
420
+ const oldGeneration = pointer.worktreeGeneration;
421
+ pointer = {
422
+ ...pointer,
423
+ worktreeId: sha256(allocation.repoRoot),
424
+ worktreeGeneration: allocation.generation,
425
+ };
426
+ await this.dependencies.activeContexts.save(pointer);
427
+ if (allocation.repoRoot !== repository.repoRoot)
428
+ return asJsonValue({
429
+ worktreeResumeRequired: true,
430
+ repoRoot: allocation.repoRoot,
431
+ taskSlug,
432
+ sessionId,
433
+ nextAction: 'Resume in this allocated directory before editing. The task branch has been preserved.',
434
+ });
435
+ await this.dependencies.gate.rebindWorktreeOwnership(repository.repoRoot, pointer.taskId, oldGeneration, allocation.generation);
436
+ }
368
437
  if (pointer?.closeIntent) {
369
438
  const recoveredClose = await this.retryCloseIntent(repository, pointer);
370
439
  return asJsonValue({
371
440
  backend: { close: recoveredClose },
441
+ worktree: this.poolAllocations.get(repository.repoRoot) ?? null,
372
442
  pendingQuestionnaires,
373
443
  repository: publicRepository(repository),
374
444
  localJournal: await this.dependencies.journal.load(projectId, taskSlug),
@@ -407,11 +477,16 @@ export class BridgeService {
407
477
  throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
408
478
  }
409
479
  await this.assertTaskWorktree(repository, backendTask, pointer);
410
- if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
480
+ if ((backendTask.status === 'closed' || backendTask.status === 'abandoned') &&
481
+ !pointer?.worktreeGeneration) {
411
482
  await this.dependencies.repositories.git
412
483
  .branchStore(repository.repoRoot)
413
484
  .release(backendTask.id);
414
485
  }
486
+ if (backendTask.status === 'closed' &&
487
+ pointer?.worktreeGeneration &&
488
+ this.dependencies.worktreePool)
489
+ await this.dependencies.worktreePool.delivery(projectId, repository.repoRoot, pointer.worktreeGeneration);
415
490
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
416
491
  let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
417
492
  const responseSource = this.dependencies.client.getResponseSource(response);
@@ -523,6 +598,9 @@ export class BridgeService {
523
598
  taskSlug,
524
599
  source: sourceFromContext(backendTask.sourceContext),
525
600
  worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
601
+ ...(pointer?.worktreeGeneration
602
+ ? { worktreeGeneration: pointer.worktreeGeneration }
603
+ : {}),
526
604
  ...(typeof backendTask.branch === 'string'
527
605
  ? { branch: backendTask.branch }
528
606
  : pointer?.branch !== undefined
@@ -543,6 +621,7 @@ export class BridgeService {
543
621
  return asJsonValue({
544
622
  backend,
545
623
  repository: publicRepository(repository),
624
+ worktree: this.poolAllocations.get(repository.repoRoot) ?? null,
546
625
  localJournal,
547
626
  pendingQuestionnaires,
548
627
  synchronization: {
@@ -572,11 +651,26 @@ export class BridgeService {
572
651
  });
573
652
  }
574
653
  async contextPrepareChange(input) {
654
+ const pointer = await this.dependencies.activeContexts.findBySessionId(input.sessionId);
655
+ if (!pointer)
656
+ return this.contextPrepareChangeUnlocked(input);
657
+ try {
658
+ return await this.taskExclusive(pointer.taskId, () => this.contextPrepareChangeUnlocked(input));
659
+ }
660
+ catch (error) {
661
+ return this.execute(async () => {
662
+ throw error;
663
+ });
664
+ }
665
+ }
666
+ async contextPrepareChangeUnlocked(input) {
575
667
  return await this.execute(async () => {
576
668
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
577
669
  const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
578
670
  if (!pointer)
579
671
  throw refuse('Resume this task before requesting a change lease.', 'session.resume');
672
+ if (!input.transitionToWrite)
673
+ await this.assertPoolPointer(repository, pointer);
580
674
  await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
581
675
  const outbox = await this.flushOutbox(pointer.taskId);
582
676
  const pendingOutbox = await this.dependencies.outbox.listForTask(pointer.taskId);
@@ -622,6 +716,12 @@ export class BridgeService {
622
716
  const refreshed = await this.activeTaskSnapshot(pointer.taskId, pointer.projectId, repository.repoRoot);
623
717
  transitionTaskVersion = refreshed.taskVersion;
624
718
  }
719
+ if (decision && this.dependencies.worktreePool) {
720
+ const allocation = this.poolAllocations.get(repository.repoRoot);
721
+ if (!allocation || allocation.externalTaskId !== pointer.taskSlug)
722
+ throw refuse('Choose this task branch and use its allocated repoRoot before transitioning to write.', 'task.branch');
723
+ await this.dependencies.worktreePool.assertOwnership(pointer.projectId, repository.repoRoot, allocation.generation);
724
+ }
625
725
  const body = cleanJson({
626
726
  sessionId: input.sessionId,
627
727
  changedPaths,
@@ -683,7 +783,15 @@ export class BridgeService {
683
783
  worktreeId: sha256(repository.repoRoot),
684
784
  branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
685
785
  mode: 'write',
786
+ ...(this.poolAllocations.get(repository.repoRoot)
787
+ ? { worktreeGeneration: this.poolAllocations.get(repository.repoRoot).generation }
788
+ : {}),
686
789
  });
790
+ const allocation = this.poolAllocations.get(repository.repoRoot);
791
+ if (allocation && this.dependencies.worktreePool) {
792
+ await this.dependencies.worktreePool.bind(preparedPointer.projectId, repository.repoRoot, allocation.generation, preparedPointer.taskId);
793
+ allocation.taskId = preparedPointer.taskId;
794
+ }
687
795
  }
688
796
  const preparedData = objectValue(response.data);
689
797
  await this.seedResumeSnapshot(preparedPointer, {
@@ -803,6 +911,19 @@ export class BridgeService {
803
911
  return notices.filter((notice) => wanted.has(notice.resourceId));
804
912
  }
805
913
  async contextRefresh(input) {
914
+ const pointer = await this.dependencies.activeContexts.findBySessionId(input.sessionId);
915
+ if (!pointer)
916
+ return this.contextRefreshUnlocked(input);
917
+ try {
918
+ return await this.taskExclusive(pointer.taskId, () => this.contextRefreshUnlocked(input));
919
+ }
920
+ catch (error) {
921
+ return this.execute(async () => {
922
+ throw error;
923
+ });
924
+ }
925
+ }
926
+ async contextRefreshUnlocked(input) {
806
927
  return await this.execute(async () => {
807
928
  const pointer = await this.dependencies.activeContexts.findBySessionId(input.sessionId);
808
929
  const repoRoot = input.repoRoot;
@@ -816,6 +937,8 @@ export class BridgeService {
816
937
  (repository.repoFingerprint !== pointer.repoFingerprint ||
817
938
  (pointer.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))))
818
939
  throw refuse('Refresh this session from its own repository and worktree.', 'session.resume');
940
+ if (pointer && repository && pointer.mode !== 'read_only')
941
+ await this.assertPoolPointer(repository, pointer);
819
942
  const source = repository
820
943
  ? await this.checkoutSource(repository, pointer?.source)
821
944
  : undefined;
@@ -923,8 +1046,10 @@ export class BridgeService {
923
1046
  return this.execute(async () => {
924
1047
  if (input.choice.mode !== 'greenfield')
925
1048
  throw refuse('Only the greenfield choice initializes Git. Use project.onboard for adoption or rework.', 'session.entry');
1049
+ if (this.dependencies.worktreePool && !input.choice.gitPreferences?.development.answered)
1050
+ throw refuse('Choose the planned development branch through the native onboarding form before initializing Git.', 'project.initialize');
926
1051
  return asJsonValue({
927
- ...(await new ProjectIntake().initialize(input.repoRoot)),
1052
+ ...(await new ProjectIntake().initialize(input.repoRoot, input.choice.gitPreferences?.development.branch ?? undefined)),
928
1053
  onboarding: stored.data,
929
1054
  });
930
1055
  });
@@ -1682,6 +1807,180 @@ export class BridgeService {
1682
1807
  });
1683
1808
  });
1684
1809
  }
1810
+ async worktreeTaskAllocation(input) {
1811
+ return this.execute(async () => {
1812
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
1813
+ const allocation = repository.projectId && this.dependencies.worktreePool
1814
+ ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
1815
+ : null;
1816
+ return asJsonValue({ allocation });
1817
+ });
1818
+ }
1819
+ async projectGitPreferences(input) {
1820
+ return this.execute(async () => {
1821
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd(), input.projectId);
1822
+ if (!repository.projectId)
1823
+ throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
1824
+ const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
1825
+ return asJsonValue({
1826
+ ...objectOrEmpty(objectValue(response.data)),
1827
+ repoRoot: repository.repoRoot,
1828
+ currentBranch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1829
+ currentCommit: repository.git.head,
1830
+ });
1831
+ });
1832
+ }
1833
+ async setProjectGitPreferences(projectId, body) {
1834
+ return this.execute(async () => {
1835
+ assertSafeToPersist(body);
1836
+ return (await this.dependencies.client.request(endpoints.projectGitPreferences(projectId), { method: 'PUT', body })).data;
1837
+ });
1838
+ }
1839
+ async worktreePolicy(input) {
1840
+ return this.execute(async () => {
1841
+ const response = await this.dependencies.client.request(endpoints.worktreePolicy, input ? { method: 'PUT', body: input } : {});
1842
+ if (!validWorktreePolicy(response.data))
1843
+ throw refuse('The backend worktree policy is invalid.', 'worktree.policy');
1844
+ await this.dependencies.worktreePolicy?.save(response.data);
1845
+ return asJsonValue(response.data);
1846
+ });
1847
+ }
1848
+ async poolPolicy(refresh = false) {
1849
+ if (refresh) {
1850
+ try {
1851
+ const response = await this.dependencies.client.request(endpoints.worktreePolicy);
1852
+ if (!validWorktreePolicy(response.data))
1853
+ throw refuse('The backend worktree policy is invalid.', 'worktree.policy');
1854
+ await this.dependencies.worktreePolicy?.save(response.data);
1855
+ return response.data;
1856
+ }
1857
+ catch (error) {
1858
+ if (!isBackendUnavailableError(error))
1859
+ throw error;
1860
+ }
1861
+ }
1862
+ if (!this.dependencies.worktreePolicy)
1863
+ throw refuse('This client has no local worktree policy cache.', 'worktree.policy');
1864
+ return this.dependencies.worktreePolicy.read();
1865
+ }
1866
+ async worktreeOperation(operation, input) {
1867
+ return this.execute(async () => {
1868
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
1869
+ const projectId = repository.projectId;
1870
+ const pool = this.dependencies.worktreePool;
1871
+ if (!projectId || !pool)
1872
+ throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
1873
+ if (operation === 'list' || operation === 'reconcile')
1874
+ return asJsonValue({
1875
+ items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1876
+ selected: await pool.forPath(projectId, repository.repoRoot),
1877
+ });
1878
+ if (operation === 'release_unowned') {
1879
+ const entry = await pool.forPath(projectId, repository.repoRoot);
1880
+ if (!entry || !input.sourceCommit || !input.generation || !input.decisionId)
1881
+ throw refuse('Inspect the recovered checkout with worktree.reconcile before confirming its release.', 'worktree.reconcile');
1882
+ const digest = sha256(stableStringify({
1883
+ projectId,
1884
+ repoRoot: entry.repoRoot,
1885
+ generation: input.generation,
1886
+ sourceCommit: input.sourceCommit,
1887
+ decisionAttempt: input.decisionAttempt ?? 0,
1888
+ }));
1889
+ if (input.decisionId !== 'worktree-release-unowned-' + digest)
1890
+ throw refuse('The availability decision does not match this checkout and source. Repeat worktree.reconcile.', 'worktree.reconcile');
1891
+ const decision = await this.questionnaireResume({
1892
+ repoRoot: repository.repoRoot,
1893
+ questionnaireId: input.decisionId,
1894
+ });
1895
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'release_unowned')
1896
+ throw refuse('Confirm that prior work and delivery have finished in the native worktree.reconcile form.', 'worktree.reconcile');
1897
+ await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
1898
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1899
+ }
1900
+ const entry = await pool.forPath(projectId, repository.repoRoot);
1901
+ if (!entry ||
1902
+ entry.externalTaskId !== input.externalTaskId ||
1903
+ entry.generation !== input.generation)
1904
+ throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
1905
+ const perform = async () => {
1906
+ if (operation === 'heartbeat')
1907
+ await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
1908
+ if (operation === 'pause') {
1909
+ await pool.pause(projectId, repository.repoRoot, input.generation);
1910
+ this.poolAllocations.delete(repository.repoRoot);
1911
+ }
1912
+ if (operation === 'release') {
1913
+ if (entry.taskId)
1914
+ await this.flushOutbox(entry.taskId);
1915
+ await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
1916
+ this.poolAllocations.delete(repository.repoRoot);
1917
+ }
1918
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1919
+ };
1920
+ return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
1921
+ });
1922
+ }
1923
+ async pooledTaskBranch(repository, input) {
1924
+ const pool = this.dependencies.worktreePool;
1925
+ const projectId = repository.projectId;
1926
+ const existing = await pool.find(projectId, input.externalTaskId, repository.repoRoot);
1927
+ const preferences = (await this.dependencies.client.request(endpoints.projectGitPreferences(projectId))).data;
1928
+ const git = this.dependencies.repositories.git;
1929
+ let source;
1930
+ if (existing)
1931
+ source = { sourceCommit: existing.baseCommit };
1932
+ else {
1933
+ if (!input.decisionId || !input.base)
1934
+ throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
1935
+ const decision = await this.questionnaireResume({
1936
+ repoRoot: repository.repoRoot,
1937
+ questionnaireId: input.decisionId,
1938
+ });
1939
+ const signature = sha256(stableStringify({
1940
+ externalTaskId: input.externalTaskId,
1941
+ base: input.base,
1942
+ name: input.name ?? null,
1943
+ keepCurrent: input.keepCurrent === true,
1944
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
1945
+ }));
1946
+ if (!['task-branch-confirm-' + signature, 'task-branch-fallback-' + signature].includes(decision.questionnaireId) ||
1947
+ decision.answer?.choice !== 'approve')
1948
+ throw refuse('The task branch decision does not match the approved source and branch.', 'task.branch');
1949
+ source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
1950
+ }
1951
+ const allocation = await pool.allocate({
1952
+ projectId,
1953
+ projectName: preferences.projectName ?? projectId,
1954
+ repoFingerprint: repository.repoFingerprint,
1955
+ repoRoot: repository.repoRoot,
1956
+ externalTaskId: input.externalTaskId,
1957
+ branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
1958
+ baseCommit: source.sourceCommit,
1959
+ keepCurrent: input.keepCurrent,
1960
+ resume: Boolean(existing),
1961
+ }, await this.poolPolicy(true));
1962
+ this.poolAllocations.set(allocation.repoRoot, allocation);
1963
+ return asJsonValue({
1964
+ ...allocation,
1965
+ kept: !allocation.managed,
1966
+ nextAction: 'Run every file, terminal, lifecycle and delivery operation from this repoRoot. Use task.heartbeat during long work. Pause before handing the task to another client.',
1967
+ });
1968
+ }
1969
+ async assertPoolPointer(repository, pointer) {
1970
+ const pool = this.dependencies.worktreePool;
1971
+ if (!pool)
1972
+ return;
1973
+ const entry = await pool.forPath(pointer.projectId, repository.repoRoot);
1974
+ if (!entry && !pointer.worktreeGeneration)
1975
+ return; // existing unmanaged tasks retain their legacy reservation
1976
+ const owned = this.poolAllocations.get(repository.repoRoot);
1977
+ if (!entry ||
1978
+ !owned ||
1979
+ owned.generation !== pointer.worktreeGeneration ||
1980
+ entry.externalTaskId !== pointer.taskSlug)
1981
+ throw refuse('Resume this task to acquire its worktree before writing or delivering.', 'session.resume');
1982
+ await pool.assertOwnership(pointer.projectId, repository.repoRoot, owned.generation);
1983
+ }
1685
1984
  async taskBranch(input) {
1686
1985
  return await this.execute(async () => {
1687
1986
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
@@ -1694,10 +1993,15 @@ export class BridgeService {
1694
1993
  input.externalTaskId.length > 160 ||
1695
1994
  /[\r\n]/.test(input.externalTaskId))
1696
1995
  throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
1996
+ if (this.dependencies.worktreePool &&
1997
+ (await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
1998
+ return this.pooledTaskBranch(repository, input);
1697
1999
  if (Boolean(input.name) === (input.keepCurrent === true) ||
1698
2000
  (input.worktreePath && !input.name))
1699
2001
  throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
1700
2002
  assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
2003
+ if (this.dependencies.worktreePool)
2004
+ return this.pooledTaskBranch(repository, input);
1701
2005
  const git = this.dependencies.repositories.git;
1702
2006
  let target = repository;
1703
2007
  try {
@@ -1743,7 +2047,7 @@ export class BridgeService {
1743
2047
  catch (error) {
1744
2048
  if (error instanceof BridgeRecoveryError)
1745
2049
  throw error;
1746
- throw refuse(`${error instanceof Error ? error.message : String(error)} Retry task.branch for the selected task; use worktreePath to leave a busy or dirty checkout intact.`, 'task.branch');
2050
+ throw refuse(`${error instanceof Error ? error.message : String(error)} Retry task.branch for the selected task; select a new branch so the managed pool preserves the busy or dirty checkout.`, 'task.branch');
1747
2051
  }
1748
2052
  });
1749
2053
  }
@@ -1766,10 +2070,10 @@ export class BridgeService {
1766
2070
  }
1767
2071
  }
1768
2072
  if (!record)
1769
- throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. For parallel work choose a separate worktreePath.`, 'task.branch');
2073
+ throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. The managed pool allocates the separate worktree; use its returned repoRoot.`, 'task.branch');
1770
2074
  if (record.decision.projectId !== repository.projectId ||
1771
2075
  record.decision.externalTaskId !== externalTaskId)
1772
- throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch with worktreePath for ${externalTaskId}, then bootstrap in the returned directory.`, 'task.branch');
2076
+ throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch to allocate a separate worktree for ${externalTaskId}, then bootstrap in the returned repoRoot.`, 'task.branch');
1773
2077
  const branch = await git.currentBranch(repository.repoRoot);
1774
2078
  if (branch !== record.decision.branch)
1775
2079
  throw refuse(`Task ${externalTaskId} selected ${record.decision.branch ?? 'detached HEAD'}, but this worktree is on ${branch ?? 'detached HEAD'}. Return to the selected branch with task.branch.`, 'task.branch');
@@ -1778,6 +2082,8 @@ export class BridgeService {
1778
2082
  async assertTaskWorktree(repository, task, pointer) {
1779
2083
  if (normalizeTaskMode(task.mode) === 'read_only')
1780
2084
  return;
2085
+ if (pointer)
2086
+ await this.assertPoolPointer(repository, pointer);
1781
2087
  if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
1782
2088
  throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
1783
2089
  const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
@@ -1797,9 +2103,15 @@ export class BridgeService {
1797
2103
  if (!abandoned || abandoned.abandoned !== true) {
1798
2104
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
1799
2105
  }
1800
- await this.dependencies.repositories.git
1801
- .branchStore(repository.repoRoot)
1802
- .release(input.taskId);
2106
+ const allocation = this.poolAllocations.get(repository.repoRoot);
2107
+ if (allocation && this.dependencies.worktreePool) {
2108
+ await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2109
+ this.poolAllocations.delete(repository.repoRoot);
2110
+ }
2111
+ else
2112
+ await this.dependencies.repositories.git
2113
+ .branchStore(repository.repoRoot)
2114
+ .release(input.taskId);
1803
2115
  const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1804
2116
  await this.dependencies.gate.invalidateTask(input.taskId);
1805
2117
  return asJsonValue({
@@ -1852,6 +2164,27 @@ export class BridgeService {
1852
2164
  readDiscoveryUnits(policy).length === 0) {
1853
2165
  throw refuse('Project setup response is not policy-ready', 'project.setup');
1854
2166
  }
2167
+ if (input.onboardingRequestKey &&
2168
+ this.dependencies.onboarding &&
2169
+ this.dependencies.worktreePool) {
2170
+ const access = await this.dependencies.credentials.get('access-token');
2171
+ const target = await canonicalPath(repository.repoRoot);
2172
+ const choice = await this.dependencies.onboarding.get({
2173
+ principalHash: access ? principalFingerprint(access) : sha256('anonymous'),
2174
+ repoFingerprint: sha256('preparation:' + (process.platform === 'win32' ? target.toLowerCase() : target)),
2175
+ projectId: null,
2176
+ }, input.onboardingRequestKey);
2177
+ if (!choice?.gitPreferences)
2178
+ throw refuse('Read the saved onboarding choice and supply its original request key.', 'project.onboarding_status');
2179
+ const current = (await this.dependencies.client.request(endpoints.projectGitPreferences(project.id))).data;
2180
+ if (current.version === 0)
2181
+ await this.dependencies.client.request(endpoints.projectGitPreferences(project.id), {
2182
+ method: 'PUT',
2183
+ body: cleanJson({ expectedVersion: 0, ...choice.gitPreferences }),
2184
+ });
2185
+ else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
2186
+ throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
2187
+ }
1855
2188
  const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
1856
2189
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
1857
2190
  });
@@ -2243,6 +2576,18 @@ export class BridgeService {
2243
2576
  const workItems = authenticated && state === 'bound' && projectId
2244
2577
  ? await this.actionableWorkItems(projectId)
2245
2578
  : [];
2579
+ const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2580
+ ? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
2581
+ : null;
2582
+ const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
2583
+ const unansweredBranches = preferences?.canManage === true
2584
+ ? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
2585
+ .filter(([, value]) => objectValue(value)?.answered === false)
2586
+ .map(([role]) => role)
2587
+ : [];
2588
+ const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2589
+ ? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
2590
+ : null;
2246
2591
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
2247
2592
  const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
2248
2593
  return asJsonValue({
@@ -2253,6 +2598,16 @@ export class BridgeService {
2253
2598
  liveTasks,
2254
2599
  workItems,
2255
2600
  taskSelectionRequired,
2601
+ ...(worktrees ? { worktrees: worktrees.ok ? worktrees.data : worktrees.error } : {}),
2602
+ ...(gitPreferences
2603
+ ? {
2604
+ gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
2605
+ unansweredBranchPreferences: unansweredBranches,
2606
+ branchPreferenceAction: unansweredBranches.length
2607
+ ? 'Ask only these unanswered shared branch preferences using native questionnaires and project.set_git_preferences. Other members choose only their task base.'
2608
+ : 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
2609
+ }
2610
+ : {}),
2256
2611
  ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
2257
2612
  client,
2258
2613
  ...(incomplete && state === 'bound'
@@ -2777,6 +3132,8 @@ export class BridgeService {
2777
3132
  (repository.projectId && pointer.projectId !== repository.projectId)) {
2778
3133
  throw refuse('Active task snapshot does not match the repository project', 'session.resume');
2779
3134
  }
3135
+ if (pointer.mode !== 'read_only')
3136
+ await this.assertPoolPointer(repository, pointer);
2780
3137
  if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
2781
3138
  throw refuse('Pending task deliveries must be resolved before using the task snapshot', 'task.resolve_pending_delivery');
2782
3139
  }
@@ -3014,9 +3371,13 @@ export class BridgeService {
3014
3371
  taskChanges,
3015
3372
  });
3016
3373
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
3017
- await this.dependencies.repositories.git
3018
- .branchStore(repository.repoRoot)
3019
- .release(String(body.taskId));
3374
+ const allocation = this.poolAllocations.get(repository.repoRoot);
3375
+ if (allocation && this.dependencies.worktreePool)
3376
+ await this.dependencies.worktreePool.delivery(allocation.projectId, repository.repoRoot, allocation.generation);
3377
+ else
3378
+ await this.dependencies.repositories.git
3379
+ .branchStore(repository.repoRoot)
3380
+ .release(String(body.taskId));
3020
3381
  return asJsonValue({
3021
3382
  ...closedTask,
3022
3383
  repository: publicRepository(repository),
@@ -3082,7 +3443,7 @@ export class BridgeService {
3082
3443
  }
3083
3444
  return pointer;
3084
3445
  }
3085
- async taskExclusive(taskId, action) {
3446
+ async taskExclusive(taskId, action, renew = true) {
3086
3447
  const previous = this.taskQueues.get(taskId) ?? Promise.resolve();
3087
3448
  let release = () => undefined;
3088
3449
  const current = new Promise((resolvePromise) => {
@@ -3091,10 +3452,34 @@ export class BridgeService {
3091
3452
  const tail = previous.then(() => current);
3092
3453
  this.taskQueues.set(taskId, tail);
3093
3454
  await previous;
3455
+ let heartbeat;
3456
+ let heartbeatFailure;
3094
3457
  try {
3095
- return await action();
3458
+ const allocation = await this.dependencies.worktreePool?.forTask(taskId);
3459
+ if (allocation) {
3460
+ const owned = this.poolAllocations.get(allocation.repoRoot);
3461
+ if (!owned || owned.generation !== allocation.generation)
3462
+ throw refuse('Resume the task in its owned worktree before this operation.', 'session.resume');
3463
+ await this.dependencies.worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation);
3464
+ const policy = await this.poolPolicy();
3465
+ if (renew)
3466
+ heartbeat = setInterval(() => {
3467
+ void this.dependencies
3468
+ .worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation)
3469
+ .catch((error) => {
3470
+ heartbeatFailure = error;
3471
+ });
3472
+ }, policy.heartbeatSeconds * 1000);
3473
+ heartbeat?.unref();
3474
+ }
3475
+ const result = await action();
3476
+ if (heartbeatFailure)
3477
+ throw heartbeatFailure;
3478
+ return result;
3096
3479
  }
3097
3480
  finally {
3481
+ if (heartbeat)
3482
+ clearInterval(heartbeat);
3098
3483
  release();
3099
3484
  if (this.taskQueues.get(taskId) === tail) {
3100
3485
  this.taskQueues.delete(taskId);
@@ -92,7 +92,7 @@ export class TaskBranchStore {
92
92
  assertSame(current, requested) {
93
93
  if (current.projectId !== requested.projectId ||
94
94
  current.externalTaskId !== requested.externalTaskId) {
95
- throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with worktreePath and a branch name for the new task.`, 'task.branch');
95
+ throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with a new task branch name to allocate a separate worktree.`, 'task.branch');
96
96
  }
97
97
  if (current.branch !== requested.branch) {
98
98
  throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');