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.
- package/dispatcher/sections.mjs +2 -0
- package/package.json +29 -29
- package/runtime/dist/src/config.js +2 -0
- package/runtime/dist/src/git/git-inspector.js +41 -2
- package/runtime/dist/src/git/verification-gate.js +40 -1
- package/runtime/dist/src/index.js +15 -1
- package/runtime/dist/src/mcp/onboarding-tools.js +39 -1
- package/runtime/dist/src/mcp/server.js +2 -0
- package/runtime/dist/src/mcp/tool-definitions.js +13 -10
- package/runtime/dist/src/mcp/worktree-tools.js +455 -0
- package/runtime/dist/src/project/project-intake.js +9 -2
- package/runtime/dist/src/runtime/api-client.js +4 -0
- package/runtime/dist/src/runtime/branch-preferences.js +53 -0
- package/runtime/dist/src/runtime/bridge-service.js +398 -13
- package/runtime/dist/src/runtime/task-branch-store.js +1 -1
- package/runtime/dist/src/runtime/worktree-policy.js +37 -0
- package/runtime/dist/src/runtime/worktree-pool.js +905 -0
- package/runtime/dist/src/utilities/local-mutex.js +37 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/lifecycle.md +52 -4
- package/skill/references/project-onboarding.md +4 -0
- package/skill/references/questionnaires.md +21 -0
|
@@ -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;
|
|
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.
|
|
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
|
|
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
|
-
|
|
1801
|
-
|
|
1802
|
-
.
|
|
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
|
-
|
|
3018
|
-
|
|
3019
|
-
.
|
|
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
|
-
|
|
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
|
|
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');
|