engineering-memory 1.11.4 → 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.
- package/dispatcher/sections.mjs +2 -0
- package/package.json +1 -1
- 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 +392 -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 +377 -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 +847 -0
- package/runtime/dist/src/utilities/local-mutex.js +37 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/lifecycle.md +12 -4
- package/skill/references/project-onboarding.md +4 -0
- package/skill/references/questionnaires.md +8 -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,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;
|
|
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.
|
|
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
|
|
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
|
-
|
|
1801
|
-
|
|
1802
|
-
.
|
|
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
|
});
|
|
@@ -2243,6 +2555,18 @@ export class BridgeService {
|
|
|
2243
2555
|
const workItems = authenticated && state === 'bound' && projectId
|
|
2244
2556
|
? await this.actionableWorkItems(projectId)
|
|
2245
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;
|
|
2246
2570
|
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
2247
2571
|
const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
|
|
2248
2572
|
return asJsonValue({
|
|
@@ -2253,6 +2577,16 @@ export class BridgeService {
|
|
|
2253
2577
|
liveTasks,
|
|
2254
2578
|
workItems,
|
|
2255
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
|
+
: {}),
|
|
2256
2590
|
...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
|
|
2257
2591
|
client,
|
|
2258
2592
|
...(incomplete && state === 'bound'
|
|
@@ -2777,6 +3111,8 @@ export class BridgeService {
|
|
|
2777
3111
|
(repository.projectId && pointer.projectId !== repository.projectId)) {
|
|
2778
3112
|
throw refuse('Active task snapshot does not match the repository project', 'session.resume');
|
|
2779
3113
|
}
|
|
3114
|
+
if (pointer.mode !== 'read_only')
|
|
3115
|
+
await this.assertPoolPointer(repository, pointer);
|
|
2780
3116
|
if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
|
|
2781
3117
|
throw refuse('Pending task deliveries must be resolved before using the task snapshot', 'task.resolve_pending_delivery');
|
|
2782
3118
|
}
|
|
@@ -3014,9 +3350,13 @@ export class BridgeService {
|
|
|
3014
3350
|
taskChanges,
|
|
3015
3351
|
});
|
|
3016
3352
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
.
|
|
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));
|
|
3020
3360
|
return asJsonValue({
|
|
3021
3361
|
...closedTask,
|
|
3022
3362
|
repository: publicRepository(repository),
|
|
@@ -3082,7 +3422,7 @@ export class BridgeService {
|
|
|
3082
3422
|
}
|
|
3083
3423
|
return pointer;
|
|
3084
3424
|
}
|
|
3085
|
-
async taskExclusive(taskId, action) {
|
|
3425
|
+
async taskExclusive(taskId, action, renew = true) {
|
|
3086
3426
|
const previous = this.taskQueues.get(taskId) ?? Promise.resolve();
|
|
3087
3427
|
let release = () => undefined;
|
|
3088
3428
|
const current = new Promise((resolvePromise) => {
|
|
@@ -3091,10 +3431,34 @@ export class BridgeService {
|
|
|
3091
3431
|
const tail = previous.then(() => current);
|
|
3092
3432
|
this.taskQueues.set(taskId, tail);
|
|
3093
3433
|
await previous;
|
|
3434
|
+
let heartbeat;
|
|
3435
|
+
let heartbeatFailure;
|
|
3094
3436
|
try {
|
|
3095
|
-
|
|
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;
|
|
3096
3458
|
}
|
|
3097
3459
|
finally {
|
|
3460
|
+
if (heartbeat)
|
|
3461
|
+
clearInterval(heartbeat);
|
|
3098
3462
|
release();
|
|
3099
3463
|
if (this.taskQueues.get(taskId) === tail) {
|
|
3100
3464
|
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');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeJson } from '../utilities/files.js';
|
|
3
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
4
|
+
export function validWorktreePolicy(value) {
|
|
5
|
+
const p = value;
|
|
6
|
+
return Boolean(p &&
|
|
7
|
+
Number.isSafeInteger(p.version) &&
|
|
8
|
+
p.version >= 1 &&
|
|
9
|
+
Number.isSafeInteger(p.maxWorktrees) &&
|
|
10
|
+
p.maxWorktrees >= 1 &&
|
|
11
|
+
p.maxWorktrees <= 500 &&
|
|
12
|
+
Number.isSafeInteger(p.heartbeatSeconds) &&
|
|
13
|
+
p.heartbeatSeconds >= 5 &&
|
|
14
|
+
p.heartbeatSeconds <= 300 &&
|
|
15
|
+
Number.isSafeInteger(p.inactivitySeconds) &&
|
|
16
|
+
p.inactivitySeconds >= 60 &&
|
|
17
|
+
p.inactivitySeconds <= 86400 &&
|
|
18
|
+
p.inactivitySeconds >= p.heartbeatSeconds * 2);
|
|
19
|
+
}
|
|
20
|
+
export class WorktreePolicyCache {
|
|
21
|
+
stateRoot;
|
|
22
|
+
constructor(stateRoot) {
|
|
23
|
+
this.stateRoot = stateRoot;
|
|
24
|
+
}
|
|
25
|
+
async save(policy) {
|
|
26
|
+
if (!validWorktreePolicy(policy))
|
|
27
|
+
throw new BridgeRecoveryError('The backend returned an invalid worktree policy.', 'worktree.policy');
|
|
28
|
+
await writeJson(join(this.stateRoot, 'worktree-policy.json'), policy, this.stateRoot);
|
|
29
|
+
}
|
|
30
|
+
async read() {
|
|
31
|
+
const value = await readJson(join(this.stateRoot, 'worktree-policy.json'), this.stateRoot);
|
|
32
|
+
if (!validWorktreePolicy(value))
|
|
33
|
+
throw new BridgeRecoveryError('No valid cached worktree policy exists. Read worktree.policy while connected before allocating a worktree.', 'worktree.policy');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=worktree-policy.js.map
|