engineering-memory 1.11.3 → 1.11.5

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,159 @@ 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')
1874
+ return asJsonValue({ items: await pool.list(projectId, await this.poolPolicy()) });
1875
+ if (operation === 'reconcile')
1876
+ return asJsonValue({
1877
+ items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1878
+ });
1879
+ const entry = await pool.forPath(projectId, repository.repoRoot);
1880
+ if (!entry ||
1881
+ entry.externalTaskId !== input.externalTaskId ||
1882
+ entry.generation !== input.generation)
1883
+ throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
1884
+ const perform = async () => {
1885
+ if (operation === 'heartbeat')
1886
+ await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
1887
+ if (operation === 'pause') {
1888
+ await pool.pause(projectId, repository.repoRoot, input.generation);
1889
+ this.poolAllocations.delete(repository.repoRoot);
1890
+ }
1891
+ if (operation === 'release') {
1892
+ if (entry.taskId)
1893
+ await this.flushOutbox(entry.taskId);
1894
+ await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
1895
+ this.poolAllocations.delete(repository.repoRoot);
1896
+ }
1897
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1898
+ };
1899
+ return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
1900
+ });
1901
+ }
1902
+ async pooledTaskBranch(repository, input) {
1903
+ const pool = this.dependencies.worktreePool;
1904
+ const projectId = repository.projectId;
1905
+ const existing = await pool.find(projectId, input.externalTaskId, repository.repoRoot);
1906
+ const preferences = (await this.dependencies.client.request(endpoints.projectGitPreferences(projectId))).data;
1907
+ const git = this.dependencies.repositories.git;
1908
+ let source;
1909
+ if (existing)
1910
+ source = { sourceCommit: existing.baseCommit };
1911
+ else {
1912
+ if (!input.decisionId || !input.base)
1913
+ throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
1914
+ const decision = await this.questionnaireResume({
1915
+ repoRoot: repository.repoRoot,
1916
+ questionnaireId: input.decisionId,
1917
+ });
1918
+ const signature = sha256(stableStringify({
1919
+ externalTaskId: input.externalTaskId,
1920
+ base: input.base,
1921
+ name: input.name ?? null,
1922
+ keepCurrent: input.keepCurrent === true,
1923
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
1924
+ }));
1925
+ if (!['task-branch-confirm-' + signature, 'task-branch-fallback-' + signature].includes(decision.questionnaireId) ||
1926
+ decision.answer?.choice !== 'approve')
1927
+ throw refuse('The task branch decision does not match the approved source and branch.', 'task.branch');
1928
+ source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
1929
+ }
1930
+ const allocation = await pool.allocate({
1931
+ projectId,
1932
+ projectName: preferences.projectName ?? projectId,
1933
+ repoFingerprint: repository.repoFingerprint,
1934
+ repoRoot: repository.repoRoot,
1935
+ externalTaskId: input.externalTaskId,
1936
+ branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
1937
+ baseCommit: source.sourceCommit,
1938
+ keepCurrent: input.keepCurrent,
1939
+ resume: Boolean(existing),
1940
+ }, await this.poolPolicy(true));
1941
+ this.poolAllocations.set(allocation.repoRoot, allocation);
1942
+ return asJsonValue({
1943
+ ...allocation,
1944
+ kept: !allocation.managed,
1945
+ 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.',
1946
+ });
1947
+ }
1948
+ async assertPoolPointer(repository, pointer) {
1949
+ const pool = this.dependencies.worktreePool;
1950
+ if (!pool)
1951
+ return;
1952
+ const entry = await pool.forPath(pointer.projectId, repository.repoRoot);
1953
+ if (!entry && !pointer.worktreeGeneration)
1954
+ return; // existing unmanaged tasks retain their legacy reservation
1955
+ const owned = this.poolAllocations.get(repository.repoRoot);
1956
+ if (!entry ||
1957
+ !owned ||
1958
+ owned.generation !== pointer.worktreeGeneration ||
1959
+ entry.externalTaskId !== pointer.taskSlug)
1960
+ throw refuse('Resume this task to acquire its worktree before writing or delivering.', 'session.resume');
1961
+ await pool.assertOwnership(pointer.projectId, repository.repoRoot, owned.generation);
1962
+ }
1685
1963
  async taskBranch(input) {
1686
1964
  return await this.execute(async () => {
1687
1965
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
@@ -1694,10 +1972,15 @@ export class BridgeService {
1694
1972
  input.externalTaskId.length > 160 ||
1695
1973
  /[\r\n]/.test(input.externalTaskId))
1696
1974
  throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
1975
+ if (this.dependencies.worktreePool &&
1976
+ (await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
1977
+ return this.pooledTaskBranch(repository, input);
1697
1978
  if (Boolean(input.name) === (input.keepCurrent === true) ||
1698
1979
  (input.worktreePath && !input.name))
1699
1980
  throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
1700
1981
  assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
1982
+ if (this.dependencies.worktreePool)
1983
+ return this.pooledTaskBranch(repository, input);
1701
1984
  const git = this.dependencies.repositories.git;
1702
1985
  let target = repository;
1703
1986
  try {
@@ -1743,7 +2026,7 @@ export class BridgeService {
1743
2026
  catch (error) {
1744
2027
  if (error instanceof BridgeRecoveryError)
1745
2028
  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');
2029
+ 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
2030
  }
1748
2031
  });
1749
2032
  }
@@ -1766,10 +2049,10 @@ export class BridgeService {
1766
2049
  }
1767
2050
  }
1768
2051
  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');
2052
+ 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
2053
  if (record.decision.projectId !== repository.projectId ||
1771
2054
  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');
2055
+ 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
2056
  const branch = await git.currentBranch(repository.repoRoot);
1774
2057
  if (branch !== record.decision.branch)
1775
2058
  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 +2061,8 @@ export class BridgeService {
1778
2061
  async assertTaskWorktree(repository, task, pointer) {
1779
2062
  if (normalizeTaskMode(task.mode) === 'read_only')
1780
2063
  return;
2064
+ if (pointer)
2065
+ await this.assertPoolPointer(repository, pointer);
1781
2066
  if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
1782
2067
  throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
1783
2068
  const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
@@ -1797,9 +2082,15 @@ export class BridgeService {
1797
2082
  if (!abandoned || abandoned.abandoned !== true) {
1798
2083
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
1799
2084
  }
1800
- await this.dependencies.repositories.git
1801
- .branchStore(repository.repoRoot)
1802
- .release(input.taskId);
2085
+ const allocation = this.poolAllocations.get(repository.repoRoot);
2086
+ if (allocation && this.dependencies.worktreePool) {
2087
+ await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2088
+ this.poolAllocations.delete(repository.repoRoot);
2089
+ }
2090
+ else
2091
+ await this.dependencies.repositories.git
2092
+ .branchStore(repository.repoRoot)
2093
+ .release(input.taskId);
1803
2094
  const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1804
2095
  await this.dependencies.gate.invalidateTask(input.taskId);
1805
2096
  return asJsonValue({
@@ -1852,6 +2143,27 @@ export class BridgeService {
1852
2143
  readDiscoveryUnits(policy).length === 0) {
1853
2144
  throw refuse('Project setup response is not policy-ready', 'project.setup');
1854
2145
  }
2146
+ if (input.onboardingRequestKey &&
2147
+ this.dependencies.onboarding &&
2148
+ this.dependencies.worktreePool) {
2149
+ const access = await this.dependencies.credentials.get('access-token');
2150
+ const target = await canonicalPath(repository.repoRoot);
2151
+ const choice = await this.dependencies.onboarding.get({
2152
+ principalHash: access ? principalFingerprint(access) : sha256('anonymous'),
2153
+ repoFingerprint: sha256('preparation:' + (process.platform === 'win32' ? target.toLowerCase() : target)),
2154
+ projectId: null,
2155
+ }, input.onboardingRequestKey);
2156
+ if (!choice?.gitPreferences)
2157
+ throw refuse('Read the saved onboarding choice and supply its original request key.', 'project.onboarding_status');
2158
+ const current = (await this.dependencies.client.request(endpoints.projectGitPreferences(project.id))).data;
2159
+ if (current.version === 0)
2160
+ await this.dependencies.client.request(endpoints.projectGitPreferences(project.id), {
2161
+ method: 'PUT',
2162
+ body: cleanJson({ expectedVersion: 0, ...choice.gitPreferences }),
2163
+ });
2164
+ else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
2165
+ throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
2166
+ }
1855
2167
  const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
1856
2168
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
1857
2169
  });
@@ -1866,13 +2178,15 @@ export class BridgeService {
1866
2178
  ...(input.discipline !== undefined ? { discipline: input.discipline } : {}),
1867
2179
  }),
1868
2180
  });
1869
- return asJsonValue({ membership: response.data });
2181
+ return asJsonValue({ membership: publicMembership(response.data) });
1870
2182
  });
1871
2183
  }
1872
2184
  async projectMemberList(input) {
1873
2185
  return await this.execute(async () => {
1874
2186
  const response = await this.dependencies.client.request(endpoints.projectMemberList(input.projectId));
1875
- return asJsonValue({ members: response.data });
2187
+ return asJsonValue({
2188
+ members: Array.isArray(response.data) ? response.data.map(publicMembership) : [],
2189
+ });
1876
2190
  });
1877
2191
  }
1878
2192
  async auditList(input) {
@@ -1904,13 +2218,15 @@ export class BridgeService {
1904
2218
  discipline: input.discipline,
1905
2219
  }),
1906
2220
  });
1907
- return asJsonValue({ membership: response.data });
2221
+ return asJsonValue({ membership: publicMembership(response.data) });
1908
2222
  });
1909
2223
  }
1910
2224
  async organizationMemberList(input) {
1911
2225
  return await this.execute(async () => {
1912
2226
  const response = await this.dependencies.client.request(endpoints.organizationMemberList(input.organizationId));
1913
- return asJsonValue({ members: response.data });
2227
+ return asJsonValue({
2228
+ members: Array.isArray(response.data) ? response.data.map(publicMembership) : [],
2229
+ });
1914
2230
  });
1915
2231
  }
1916
2232
  async organizationList() {
@@ -2239,6 +2555,18 @@ export class BridgeService {
2239
2555
  const workItems = authenticated && state === 'bound' && projectId
2240
2556
  ? await this.actionableWorkItems(projectId)
2241
2557
  : [];
2558
+ const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2559
+ ? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
2560
+ : null;
2561
+ const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
2562
+ const unansweredBranches = preferences?.canManage === true
2563
+ ? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
2564
+ .filter(([, value]) => objectValue(value)?.answered === false)
2565
+ .map(([role]) => role)
2566
+ : [];
2567
+ const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2568
+ ? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
2569
+ : null;
2242
2570
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
2243
2571
  const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
2244
2572
  return asJsonValue({
@@ -2249,6 +2577,16 @@ export class BridgeService {
2249
2577
  liveTasks,
2250
2578
  workItems,
2251
2579
  taskSelectionRequired,
2580
+ ...(worktrees ? { worktrees: worktrees.ok ? worktrees.data : worktrees.error } : {}),
2581
+ ...(gitPreferences
2582
+ ? {
2583
+ gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
2584
+ unansweredBranchPreferences: unansweredBranches,
2585
+ branchPreferenceAction: unansweredBranches.length
2586
+ ? 'Ask only these unanswered shared branch preferences using native questionnaires and project.set_git_preferences. Other members choose only their task base.'
2587
+ : 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
2588
+ }
2589
+ : {}),
2252
2590
  ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
2253
2591
  client,
2254
2592
  ...(incomplete && state === 'bound'
@@ -2773,6 +3111,8 @@ export class BridgeService {
2773
3111
  (repository.projectId && pointer.projectId !== repository.projectId)) {
2774
3112
  throw refuse('Active task snapshot does not match the repository project', 'session.resume');
2775
3113
  }
3114
+ if (pointer.mode !== 'read_only')
3115
+ await this.assertPoolPointer(repository, pointer);
2776
3116
  if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
2777
3117
  throw refuse('Pending task deliveries must be resolved before using the task snapshot', 'task.resolve_pending_delivery');
2778
3118
  }
@@ -3010,9 +3350,13 @@ export class BridgeService {
3010
3350
  taskChanges,
3011
3351
  });
3012
3352
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
3013
- await this.dependencies.repositories.git
3014
- .branchStore(repository.repoRoot)
3015
- .release(String(body.taskId));
3353
+ const allocation = this.poolAllocations.get(repository.repoRoot);
3354
+ if (allocation && this.dependencies.worktreePool)
3355
+ await this.dependencies.worktreePool.delivery(allocation.projectId, repository.repoRoot, allocation.generation);
3356
+ else
3357
+ await this.dependencies.repositories.git
3358
+ .branchStore(repository.repoRoot)
3359
+ .release(String(body.taskId));
3016
3360
  return asJsonValue({
3017
3361
  ...closedTask,
3018
3362
  repository: publicRepository(repository),
@@ -3078,7 +3422,7 @@ export class BridgeService {
3078
3422
  }
3079
3423
  return pointer;
3080
3424
  }
3081
- async taskExclusive(taskId, action) {
3425
+ async taskExclusive(taskId, action, renew = true) {
3082
3426
  const previous = this.taskQueues.get(taskId) ?? Promise.resolve();
3083
3427
  let release = () => undefined;
3084
3428
  const current = new Promise((resolvePromise) => {
@@ -3087,10 +3431,34 @@ export class BridgeService {
3087
3431
  const tail = previous.then(() => current);
3088
3432
  this.taskQueues.set(taskId, tail);
3089
3433
  await previous;
3434
+ let heartbeat;
3435
+ let heartbeatFailure;
3090
3436
  try {
3091
- return await action();
3437
+ const allocation = await this.dependencies.worktreePool?.forTask(taskId);
3438
+ if (allocation) {
3439
+ const owned = this.poolAllocations.get(allocation.repoRoot);
3440
+ if (!owned || owned.generation !== allocation.generation)
3441
+ throw refuse('Resume the task in its owned worktree before this operation.', 'session.resume');
3442
+ await this.dependencies.worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation);
3443
+ const policy = await this.poolPolicy();
3444
+ if (renew)
3445
+ heartbeat = setInterval(() => {
3446
+ void this.dependencies
3447
+ .worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation)
3448
+ .catch((error) => {
3449
+ heartbeatFailure = error;
3450
+ });
3451
+ }, policy.heartbeatSeconds * 1000);
3452
+ heartbeat?.unref();
3453
+ }
3454
+ const result = await action();
3455
+ if (heartbeatFailure)
3456
+ throw heartbeatFailure;
3457
+ return result;
3092
3458
  }
3093
3459
  finally {
3460
+ if (heartbeat)
3461
+ clearInterval(heartbeat);
3094
3462
  release();
3095
3463
  if (this.taskQueues.get(taskId) === tail) {
3096
3464
  this.taskQueues.delete(taskId);
@@ -3737,4 +4105,26 @@ function sourceFromContext(value) {
3737
4105
  ...(linked ? { linked } : {}),
3738
4106
  };
3739
4107
  }
4108
+ function publicMembership(value) {
4109
+ const membership = objectValue(value);
4110
+ if (!membership)
4111
+ return {};
4112
+ const result = {};
4113
+ for (const key of [
4114
+ 'id',
4115
+ 'organizationId',
4116
+ 'projectId',
4117
+ 'userId',
4118
+ 'role',
4119
+ 'discipline',
4120
+ 'administrativeRole',
4121
+ 'createdAt',
4122
+ 'updatedAt',
4123
+ ]) {
4124
+ const field = membership[key];
4125
+ if (typeof field === 'string' || field === null)
4126
+ result[key] = field;
4127
+ }
4128
+ return result;
4129
+ }
3740
4130
  //# sourceMappingURL=bridge-service.js.map