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.
- 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 +15 -12
- 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 +407 -17
- 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 +14 -6
- package/skill/references/project-onboarding.md +4 -0
- package/skill/references/questionnaires.md +11 -1
|
@@ -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
|
});
|
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
3014
|
-
|
|
3015
|
-
.
|
|
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
|
-
|
|
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
|