engineering-memory 1.11.9 → 1.11.11
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 +7 -2
- package/install/codex-approval.mjs +457 -0
- package/install/files.mjs +5 -1
- package/install/installer.mjs +24 -1
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +2 -0
- package/runtime/dist/src/git/git-inspector.js +114 -4
- package/runtime/dist/src/git/verification-gate.js +30 -17
- package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
- package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
- package/runtime/dist/src/mcp/tool-annotations.js +112 -0
- package/runtime/dist/src/mcp/tool-definitions.js +150 -29
- package/runtime/dist/src/mcp/worktree-tools.js +395 -236
- package/runtime/dist/src/project/repository.js +26 -8
- package/runtime/dist/src/runtime/active-context-store.js +3 -0
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/branch-preferences.js +0 -19
- package/runtime/dist/src/runtime/bridge-service.js +1320 -246
- package/runtime/dist/src/runtime/offline-outbox.js +33 -158
- package/runtime/dist/src/runtime/phase-timer.js +26 -0
- package/runtime/dist/src/runtime/privacy-detector.js +263 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
- package/runtime/dist/src/runtime/recovery-error.js +3 -1
- package/runtime/dist/src/runtime/runtime-entry.js +8 -0
- package/runtime/dist/src/runtime/runtime-host.js +1 -3
- package/runtime/dist/src/runtime/task-branch-store.js +17 -1
- package/runtime/dist/src/runtime/task-start.js +281 -0
- package/runtime/dist/src/runtime/worktree-pool.js +374 -26
- package/runtime/dist/src/utilities/process.js +1 -0
- package/skill/SKILL.md +3 -3
- package/skill/references/lifecycle.md +59 -19
- package/skill/references/memory-updates.md +9 -3
- package/skill/references/project-onboarding.md +19 -2
- package/skill/references/questionnaires.md +86 -15
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unreadableWorktree, } from './worktree-pool.js';
|
|
1
2
|
import { validWorktreePolicy } from './worktree-policy.js';
|
|
2
3
|
import { resolveTaskBase } from './branch-preferences.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -8,10 +9,12 @@ import { ProjectIntake } from '../project/project-intake.js';
|
|
|
8
9
|
import { canonicalPath } from '../utilities/files.js';
|
|
9
10
|
import { minimatch } from 'minimatch';
|
|
10
11
|
import { endpoints } from '../config.js';
|
|
12
|
+
import { startPhaseTimer } from './phase-timer.js';
|
|
11
13
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
12
14
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
13
15
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
14
16
|
import { principalFingerprint } from './principal-state.js';
|
|
17
|
+
import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
|
|
15
18
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
16
19
|
export const validationIds = [
|
|
17
20
|
'format',
|
|
@@ -38,6 +41,7 @@ export class BridgeService {
|
|
|
38
41
|
deliveryQueues = new Map();
|
|
39
42
|
deliveryWaiters = [];
|
|
40
43
|
activeDeliveries = 0;
|
|
44
|
+
workedTasks = new Map();
|
|
41
45
|
constructor(dependencies) {
|
|
42
46
|
this.dependencies = dependencies;
|
|
43
47
|
this.deferDeliveries = dependencies.deferDeliveries ?? true;
|
|
@@ -45,10 +49,22 @@ export class BridgeService {
|
|
|
45
49
|
managesWorktrees() {
|
|
46
50
|
return Boolean(this.dependencies.worktreePool);
|
|
47
51
|
}
|
|
48
|
-
async questionnaireAsk(input, previousDefinitions = []) {
|
|
52
|
+
async questionnaireAsk(input, previousDefinitions = [], owner) {
|
|
49
53
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
50
|
-
const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
|
|
51
|
-
|
|
54
|
+
const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
|
|
55
|
+
const askingTask = owner || input.preparation
|
|
56
|
+
? null
|
|
57
|
+
: await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
|
|
58
|
+
return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
|
|
59
|
+
}
|
|
60
|
+
async checkoutTask(repoFingerprint, repoRoot) {
|
|
61
|
+
const worktreeId = sha256(await canonicalPath(repoRoot));
|
|
62
|
+
const task = this.workedTasks.get(worktreeId);
|
|
63
|
+
if (!task)
|
|
64
|
+
return null;
|
|
65
|
+
return (await this.dependencies.activeContexts.list(repoFingerprint)).some((pointer) => pointer.taskSlug === task && pointer.worktreeId === worktreeId)
|
|
66
|
+
? task
|
|
67
|
+
: null;
|
|
52
68
|
}
|
|
53
69
|
async questionnaireResume(input) {
|
|
54
70
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
@@ -66,18 +82,38 @@ export class BridgeService {
|
|
|
66
82
|
if (stableStringify(scope) !== stableStringify(input.scope)) {
|
|
67
83
|
throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
|
|
68
84
|
}
|
|
69
|
-
return await this.
|
|
85
|
+
return await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
|
|
86
|
+
}
|
|
87
|
+
async acceptWithScope(scope, questionnaireId, requestKey, answer, answerSource) {
|
|
88
|
+
return this.dependencies.questionnaires.accept(scope, questionnaireId, requestKey, answer, answerSource);
|
|
89
|
+
}
|
|
90
|
+
retryHint(record, repoRoot, attemptOffset = 0) {
|
|
91
|
+
if (!record.owner || record.owner.tool === 'questionnaire.ask')
|
|
92
|
+
return undefined;
|
|
93
|
+
const attempt = (record.owner.decisionAttempt ?? 0) + attemptOffset;
|
|
94
|
+
return {
|
|
95
|
+
tool: record.owner.tool,
|
|
96
|
+
arguments: {
|
|
97
|
+
...record.owner.retryArguments,
|
|
98
|
+
repoRoot,
|
|
99
|
+
...(record.owner.externalTaskId ? { externalTaskId: record.owner.externalTaskId } : {}),
|
|
100
|
+
...(attempt > 0 ? { decisionAttempt: attempt } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
70
103
|
}
|
|
71
104
|
async questionnaireAnswerFromHost(input) {
|
|
72
|
-
const
|
|
105
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
106
|
+
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
107
|
+
if (!record)
|
|
108
|
+
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
73
109
|
if (record.requestKey !== input.requestKey || record.contentHash !== input.contentHash) {
|
|
74
110
|
throw refuse('The host answer belongs to a different question or content. Read the original with questionnaire.resume; do not reuse this answer for a changed decision.', 'questionnaire.resume');
|
|
75
111
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
112
|
+
const resolved = await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, (input.answer ?? input.answers), { kind: 'host_native_relay', hostTool: input.hostTool });
|
|
113
|
+
return {
|
|
114
|
+
...resolved,
|
|
115
|
+
retry: this.retryHint(resolved.record, input.repoRoot ?? process.cwd()),
|
|
116
|
+
};
|
|
81
117
|
}
|
|
82
118
|
async questionnaireScope(repoRoot, preparation = false) {
|
|
83
119
|
await this.dependencies.principalState?.ensure();
|
|
@@ -99,7 +135,7 @@ export class BridgeService {
|
|
|
99
135
|
}
|
|
100
136
|
let repository;
|
|
101
137
|
try {
|
|
102
|
-
repository = await this.dependencies.repositories.
|
|
138
|
+
repository = await this.dependencies.repositories.resolveIdentity(lookup);
|
|
103
139
|
}
|
|
104
140
|
catch (error) {
|
|
105
141
|
if (!preparation ||
|
|
@@ -128,18 +164,64 @@ export class BridgeService {
|
|
|
128
164
|
projectId: preparation ? null : repository.projectId,
|
|
129
165
|
};
|
|
130
166
|
}
|
|
131
|
-
async
|
|
167
|
+
async questionnaireOverview(repoRoot, currentExternalTaskId, liveTaskSlugs = new Set()) {
|
|
132
168
|
const scope = await this.questionnaireScope(repoRoot);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
169
|
+
const records = await this.dependencies.questionnaires.list(scope);
|
|
170
|
+
const mine = [];
|
|
171
|
+
const other = [];
|
|
172
|
+
for (const record of records.filter((item) => item.status === 'pending')) {
|
|
173
|
+
const ownerTask = record.owner?.externalTaskId;
|
|
174
|
+
if (!ownerTask || ownerTask === currentExternalTaskId)
|
|
175
|
+
mine.push(asJsonValue({
|
|
176
|
+
questionnaireId: record.questionnaireId,
|
|
177
|
+
message: record.message,
|
|
178
|
+
...(record.questions
|
|
179
|
+
? {
|
|
180
|
+
questions: record.questions,
|
|
181
|
+
...(record.terminalChoices ? { terminalChoices: record.terminalChoices } : {}),
|
|
182
|
+
}
|
|
183
|
+
: {
|
|
184
|
+
options: record.options,
|
|
185
|
+
allowFreeText: record.allowFreeText,
|
|
186
|
+
...(record.textField ? { textField: record.textField } : {}),
|
|
187
|
+
}),
|
|
188
|
+
createdAt: record.createdAt,
|
|
189
|
+
status: record.status,
|
|
190
|
+
nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. If the user explicitly cancels this decision, call questionnaire.withdraw. Unrelated pending decisions never block independently authorized work.',
|
|
191
|
+
}));
|
|
192
|
+
else
|
|
193
|
+
other.push(asJsonValue({
|
|
194
|
+
questionnaireId: record.questionnaireId,
|
|
195
|
+
ownerTask,
|
|
196
|
+
createdAt: record.createdAt,
|
|
197
|
+
summary: record.message.slice(0, 120),
|
|
198
|
+
nextAction: `A pending question of task ${ownerTask}. Resume it with questionnaire.resume only in the chat working on ${ownerTask}, or once the user picks ${ownerTask} here; otherwise leave it alone.`,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
const latestStart = new Map();
|
|
202
|
+
for (const record of records) {
|
|
203
|
+
const task = record.owner?.tool === 'task.branch' ? record.owner.externalTaskId : undefined;
|
|
204
|
+
const current = task ? latestStart.get(task) : undefined;
|
|
205
|
+
if (task &&
|
|
206
|
+
record.status !== 'withdrawn' &&
|
|
207
|
+
(!current || (record.owner?.decisionAttempt ?? 0) >= (current.owner?.decisionAttempt ?? 0)))
|
|
208
|
+
latestStart.set(task, record);
|
|
209
|
+
}
|
|
210
|
+
const deferred = [...latestStart.entries()]
|
|
211
|
+
.filter(([task, record]) => !liveTaskSlugs.has(task) &&
|
|
212
|
+
(taskStartChoice(record)?.status === 'deferred' ||
|
|
213
|
+
(record.questionnaireId.startsWith('task-branch-fallback-') &&
|
|
214
|
+
record.status === 'answered' &&
|
|
215
|
+
record.answer?.choice === 'defer')))
|
|
216
|
+
.sort(([, left], [, right]) => (left.answeredAt ?? left.createdAt).localeCompare(right.answeredAt ?? right.createdAt))
|
|
217
|
+
.slice(-5)
|
|
218
|
+
.map(([externalTaskId, record]) => asJsonValue({
|
|
219
|
+
externalTaskId,
|
|
220
|
+
deferredAt: record.answeredAt ?? record.createdAt,
|
|
221
|
+
reconsider: this.retryHint(record, repoRoot, 1),
|
|
222
|
+
nextAction: `Starting ${externalTaskId} is on hold. Mention it only if the user brings that task up, and call reconsider only when they ask to start it.`,
|
|
142
223
|
}));
|
|
224
|
+
return { mine, other, deferred };
|
|
143
225
|
}
|
|
144
226
|
deliverInBackground(taskId) {
|
|
145
227
|
if (!this.deferDeliveries)
|
|
@@ -182,13 +264,7 @@ export class BridgeService {
|
|
|
182
264
|
});
|
|
183
265
|
let projectId = repository.projectId;
|
|
184
266
|
if (!repository.bindingPath) {
|
|
185
|
-
const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
186
|
-
method: 'POST',
|
|
187
|
-
body: {
|
|
188
|
-
repoFingerprint: repository.repoFingerprint,
|
|
189
|
-
legacyFingerprints: repository.legacyFingerprints,
|
|
190
|
-
},
|
|
191
|
-
});
|
|
267
|
+
const resolved = await this.dependencies.client.request(endpoints.projectResolve, { method: 'POST', body: await this.checkoutIdentity(repository) });
|
|
192
268
|
const project = objectValue(resolved.data);
|
|
193
269
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
194
270
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
@@ -202,6 +278,7 @@ export class BridgeService {
|
|
|
202
278
|
projects: projects.data,
|
|
203
279
|
outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
|
|
204
280
|
...(restoreRequired ? restoreGuidance : {}),
|
|
281
|
+
...movedProjectGuidance(project),
|
|
205
282
|
});
|
|
206
283
|
}
|
|
207
284
|
if (!projectId) {
|
|
@@ -227,7 +304,7 @@ export class BridgeService {
|
|
|
227
304
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
228
305
|
}
|
|
229
306
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
230
|
-
const response = await this.
|
|
307
|
+
const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
|
|
231
308
|
method: 'POST',
|
|
232
309
|
idempotencyKey: checkpointId,
|
|
233
310
|
body: cleanJson({
|
|
@@ -240,6 +317,7 @@ export class BridgeService {
|
|
|
240
317
|
workItemId: persistedBootstrap.workItemId,
|
|
241
318
|
mode: persistedBootstrap.mode ?? 'write',
|
|
242
319
|
repoFingerprint: repository.repoFingerprint,
|
|
320
|
+
checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
|
|
243
321
|
...(input.mode === 'read_only'
|
|
244
322
|
? { baselineDiffHash: repository.git.worktreeHash }
|
|
245
323
|
: {}),
|
|
@@ -274,6 +352,10 @@ export class BridgeService {
|
|
|
274
352
|
poolAllocation.taskId = task.id;
|
|
275
353
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
276
354
|
}
|
|
355
|
+
const replayedPointer = existingTask?.taskId === task.id ? existingTask : null;
|
|
356
|
+
const recordedBaselineDiffHash = typeof data.changeBaselineDiffHash === 'string' ? data.changeBaselineDiffHash : undefined;
|
|
357
|
+
const repairedBaseline = repairChangeBaseline(replayedPointer?.changeBaseline, recordedBaselineDiffHash, repository.git.head);
|
|
358
|
+
const replayedBaseline = repairedBaseline ?? replayedPointer?.changeBaseline;
|
|
277
359
|
const pointer = {
|
|
278
360
|
repoFingerprint: repository.repoFingerprint,
|
|
279
361
|
projectId,
|
|
@@ -289,9 +371,21 @@ export class BridgeService {
|
|
|
289
371
|
sessionId: session.id,
|
|
290
372
|
lastSequence: numericSequence(task.lastSequence),
|
|
291
373
|
taskVersion: numericTaskVersion(task.lockVersion),
|
|
292
|
-
resumeConflicts: [
|
|
374
|
+
resumeConflicts: [
|
|
375
|
+
...new Set([
|
|
376
|
+
...(replayedPointer?.resumeConflicts.filter((conflict) => contextRefreshConflicts.includes(conflict)) ?? []),
|
|
377
|
+
...(data.requiresContextRefresh === true ? ['context_refresh_required'] : []),
|
|
378
|
+
...(recordedBaselineDiffHash &&
|
|
379
|
+
replayedBaseline &&
|
|
380
|
+
replayedBaseline.diffHash !== recordedBaselineDiffHash
|
|
381
|
+
? ['task_change_baseline_recaptured']
|
|
382
|
+
: []),
|
|
383
|
+
]),
|
|
384
|
+
],
|
|
385
|
+
...(repairedBaseline ? { changeBaseline: repairedBaseline } : {}),
|
|
293
386
|
};
|
|
294
387
|
await this.dependencies.activeContexts.save(pointer);
|
|
388
|
+
this.workedTasks.set(sha256(repository.repoRoot), input.externalTaskId);
|
|
295
389
|
await this.dependencies.journal.apply({
|
|
296
390
|
eventId: checkpointId,
|
|
297
391
|
taskId: task.id,
|
|
@@ -312,9 +406,10 @@ export class BridgeService {
|
|
|
312
406
|
await this.seedResumeSnapshot(pointer, data);
|
|
313
407
|
return asJsonValue({
|
|
314
408
|
...data,
|
|
409
|
+
checkpoint: checkpointView(checkpoint),
|
|
315
410
|
repository: publicRepository(repository),
|
|
316
|
-
localJournal: await this.dependencies.journal.load(projectId, input.externalTaskId),
|
|
317
|
-
outbox: { recoveredDeliveries, ...
|
|
411
|
+
localJournal: journalSummary(await this.dependencies.journal.load(projectId, input.externalTaskId)),
|
|
412
|
+
outbox: { recoveredDeliveries, ...outboxSummary(outbox) },
|
|
318
413
|
});
|
|
319
414
|
});
|
|
320
415
|
}
|
|
@@ -328,12 +423,12 @@ export class BridgeService {
|
|
|
328
423
|
nextAction: entryNextAction(false, 'disabled'),
|
|
329
424
|
});
|
|
330
425
|
}
|
|
331
|
-
const
|
|
426
|
+
const earlyPendingQuestionnaires = await this.questionnaireOverview(repository.repoRoot, input.taskSlug ?? null);
|
|
332
427
|
const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
|
|
333
|
-
if (!signedIn &&
|
|
428
|
+
if (!signedIn && earlyPendingQuestionnaires.mine.length > 0) {
|
|
334
429
|
return asJsonValue({
|
|
335
430
|
authenticated: false,
|
|
336
|
-
|
|
431
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
337
432
|
repository: publicRepository(repository),
|
|
338
433
|
nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
|
|
339
434
|
});
|
|
@@ -357,7 +452,7 @@ export class BridgeService {
|
|
|
357
452
|
return asJsonValue({
|
|
358
453
|
taskChoiceRequired: true,
|
|
359
454
|
requestedTaskSlug: input.taskSlug,
|
|
360
|
-
|
|
455
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
361
456
|
liveTasks: live.map(describePointer),
|
|
362
457
|
repository: publicRepository(repository),
|
|
363
458
|
});
|
|
@@ -366,7 +461,7 @@ export class BridgeService {
|
|
|
366
461
|
return asJsonValue({
|
|
367
462
|
taskChoiceRequired: true,
|
|
368
463
|
requestedSessionId: input.sessionId,
|
|
369
|
-
|
|
464
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
370
465
|
liveTasks: live.map(describePointer),
|
|
371
466
|
repository: publicRepository(repository),
|
|
372
467
|
});
|
|
@@ -374,7 +469,7 @@ export class BridgeService {
|
|
|
374
469
|
if (!pointer && live.length > 1) {
|
|
375
470
|
return asJsonValue({
|
|
376
471
|
taskChoiceRequired: true,
|
|
377
|
-
|
|
472
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
378
473
|
liveTasks: live.map(describePointer),
|
|
379
474
|
repository: publicRepository(repository),
|
|
380
475
|
});
|
|
@@ -382,10 +477,13 @@ export class BridgeService {
|
|
|
382
477
|
const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
|
|
383
478
|
const taskSlug = input.taskSlug ?? pointer?.taskSlug;
|
|
384
479
|
const sessionId = input.sessionId ?? pointer?.sessionId;
|
|
480
|
+
const pendingQuestionnaires = taskSlug && taskSlug !== (input.taskSlug ?? null)
|
|
481
|
+
? await this.questionnaireOverview(repository.repoRoot, taskSlug)
|
|
482
|
+
: earlyPendingQuestionnaires;
|
|
385
483
|
if (!projectId || !taskSlug || !sessionId) {
|
|
386
|
-
if (pendingQuestionnaires.length > 0) {
|
|
484
|
+
if (pendingQuestionnaires.mine.length > 0) {
|
|
387
485
|
return asJsonValue({
|
|
388
|
-
pendingQuestionnaires,
|
|
486
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
389
487
|
repository: publicRepository(repository),
|
|
390
488
|
nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
|
|
391
489
|
});
|
|
@@ -405,7 +503,17 @@ export class BridgeService {
|
|
|
405
503
|
this.dependencies.worktreePool
|
|
406
504
|
? await this.dependencies.worktreePool.find(projectId, taskSlug, repository.repoRoot)
|
|
407
505
|
: null;
|
|
408
|
-
|
|
506
|
+
const parked = pointer &&
|
|
507
|
+
!pointer.worktreeId &&
|
|
508
|
+
!pointer.worktreeGeneration &&
|
|
509
|
+
!pointer.closedAt &&
|
|
510
|
+
normalizeTaskMode(pointer.mode) !== 'read_only' &&
|
|
511
|
+
typeof pointer.branch === 'string' &&
|
|
512
|
+
(await this.dependencies.repositories.git.currentBranch(repository.repoRoot)) !==
|
|
513
|
+
pointer.branch;
|
|
514
|
+
if (pointer &&
|
|
515
|
+
this.dependencies.worktreePool &&
|
|
516
|
+
(pointer.worktreeGeneration || adoptable || parked)) {
|
|
409
517
|
const pool = this.dependencies.worktreePool;
|
|
410
518
|
const previous = adoptable ?? (await pool.find(projectId, taskSlug, repository.repoRoot));
|
|
411
519
|
if (previous && previous.repoRoot !== repository.repoRoot)
|
|
@@ -416,10 +524,14 @@ export class BridgeService {
|
|
|
416
524
|
sessionId,
|
|
417
525
|
nextAction: 'Call session.resume from this returned repoRoot and use it for every subsequent command.',
|
|
418
526
|
});
|
|
419
|
-
const sourceCommit = previous?.baseCommit ?? pointer.source?.sourceCommit;
|
|
420
527
|
const branch = pointer.worktreeGeneration
|
|
421
528
|
? pointer.branch
|
|
422
529
|
: (previous?.branch ?? pointer.branch);
|
|
530
|
+
const sourceCommit = previous?.baseCommit ??
|
|
531
|
+
pointer.source?.sourceCommit ??
|
|
532
|
+
(parked && branch
|
|
533
|
+
? (await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot, 'refs/heads/' + branch))?.sourceCommit
|
|
534
|
+
: undefined);
|
|
423
535
|
if ((!sourceCommit && !(previous && previous.baseCommit === null && !previous.managed)) ||
|
|
424
536
|
branch === undefined)
|
|
425
537
|
throw refuse('The task branch and base must be restored before resuming its worktree.', 'worktree.reconcile');
|
|
@@ -455,12 +567,13 @@ export class BridgeService {
|
|
|
455
567
|
});
|
|
456
568
|
await this.dependencies.gate.rebindWorktreeOwnership(repository.repoRoot, pointer.taskId, oldGeneration, allocation.generation);
|
|
457
569
|
}
|
|
570
|
+
const checkout = await this.taskRepository(repository, pointer);
|
|
458
571
|
if (pointer?.closeIntent) {
|
|
459
|
-
const recoveredClose = await this.retryCloseIntent(
|
|
572
|
+
const recoveredClose = await this.retryCloseIntent(checkout, pointer);
|
|
460
573
|
return asJsonValue({
|
|
461
574
|
backend: { close: recoveredClose },
|
|
462
575
|
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
463
|
-
pendingQuestionnaires,
|
|
576
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
464
577
|
repository: publicRepository(repository),
|
|
465
578
|
localJournal: await this.dependencies.journal.load(projectId, taskSlug),
|
|
466
579
|
synchronization: {
|
|
@@ -474,7 +587,7 @@ export class BridgeService {
|
|
|
474
587
|
const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
|
|
475
588
|
const outbox = await this.flushOutbox(pointer?.taskId);
|
|
476
589
|
const afterSequence = input.afterSequence ?? 0;
|
|
477
|
-
const source = await this.checkoutSource(
|
|
590
|
+
const source = await this.checkoutSource(checkout, pointer?.source, pointer ?? undefined);
|
|
478
591
|
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
479
592
|
method: 'POST',
|
|
480
593
|
cacheKey: resumeCacheKey(sessionId, repository.repoFingerprint, afterSequence),
|
|
@@ -512,7 +625,7 @@ export class BridgeService {
|
|
|
512
625
|
let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
|
|
513
626
|
const responseSource = this.dependencies.client.getResponseSource(response);
|
|
514
627
|
const recoveredVerification = responseSource !== 'stale_cache' && pointer
|
|
515
|
-
? await this.retryVerificationRecovery(
|
|
628
|
+
? await this.retryVerificationRecovery(checkout, pointer, backend)
|
|
516
629
|
: null;
|
|
517
630
|
if (recoveredVerification) {
|
|
518
631
|
pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
|
|
@@ -576,6 +689,11 @@ export class BridgeService {
|
|
|
576
689
|
? await this.dependencies.journal.readDocuments(projectId, taskSlug)
|
|
577
690
|
: {};
|
|
578
691
|
const conflicts = resumeConflicts(backend, backendTask, pointer, localJournal, localDocuments, pendingOutbox, responseSource);
|
|
692
|
+
if (backend.requiresContextRefresh === true)
|
|
693
|
+
conflicts.push('context_refresh_required');
|
|
694
|
+
if (pointer?.resumeConflicts.includes('approved_memory_revision_requires_refresh'))
|
|
695
|
+
conflicts.push('approved_memory_revision_requires_refresh');
|
|
696
|
+
const contextRefreshRequired = conflicts.some((conflict) => contextRefreshConflicts.includes(conflict));
|
|
579
697
|
const resumedLease = objectValue(backend.activeLease);
|
|
580
698
|
const resumeIdentityMatches = pointer !== null &&
|
|
581
699
|
pointer.repoFingerprint === repository.repoFingerprint &&
|
|
@@ -607,11 +725,12 @@ export class BridgeService {
|
|
|
607
725
|
const resumedTaskVersion = responseSource === 'stale_cache' && pointer
|
|
608
726
|
? Math.max(pointer.taskVersion, numericTaskVersion(backendTask.lockVersion))
|
|
609
727
|
: numericTaskVersion(backendTask.lockVersion);
|
|
610
|
-
const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict));
|
|
728
|
+
const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict) && !contextRefreshConflicts.includes(conflict));
|
|
611
729
|
if (pointer && (!resumeIdentityMatches || hasBlockingConflict)) {
|
|
612
730
|
await this.dependencies.activeContexts.save({ ...pointer, resumeConflicts: conflicts });
|
|
613
731
|
}
|
|
614
732
|
else {
|
|
733
|
+
this.workedTasks.set(sha256(repository.repoRoot), taskSlug);
|
|
615
734
|
await this.dependencies.activeContexts.save({
|
|
616
735
|
repoFingerprint: repository.repoFingerprint,
|
|
617
736
|
projectId,
|
|
@@ -637,22 +756,33 @@ export class BridgeService {
|
|
|
637
756
|
? { verificationIntent: pointer.verificationIntent }
|
|
638
757
|
: {}),
|
|
639
758
|
...(pointer?.closeIntent ? { closeIntent: pointer.closeIntent } : {}),
|
|
759
|
+
...(backendTask.status === 'closed'
|
|
760
|
+
? { closedAt: pointer?.closedAt ?? new Date().toISOString() }
|
|
761
|
+
: {}),
|
|
640
762
|
});
|
|
641
763
|
}
|
|
764
|
+
const backendFresh = responseSource !== 'stale_cache';
|
|
642
765
|
return asJsonValue({
|
|
643
|
-
backend,
|
|
766
|
+
backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
|
|
767
|
+
requirements: backend.requirements ?? null,
|
|
768
|
+
...(contextRefreshRequired
|
|
769
|
+
? {
|
|
770
|
+
staleReason: backend.staleReason ?? null,
|
|
771
|
+
nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
|
|
772
|
+
}
|
|
773
|
+
: {}),
|
|
644
774
|
repository: publicRepository(repository),
|
|
645
775
|
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
646
|
-
localJournal,
|
|
647
|
-
pendingQuestionnaires,
|
|
776
|
+
localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
|
|
777
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
648
778
|
synchronization: {
|
|
649
779
|
backendSource: responseSource,
|
|
650
|
-
backendFresh
|
|
780
|
+
backendFresh,
|
|
651
781
|
hydratedFromBackend,
|
|
652
782
|
mergedFromBackend,
|
|
653
783
|
blockedJournalEventIds,
|
|
654
784
|
recoveredDeliveries,
|
|
655
|
-
outbox,
|
|
785
|
+
outbox: outboxSummary(outbox),
|
|
656
786
|
pendingOutboxCount: pendingOutbox.length,
|
|
657
787
|
pendingOutbox: pendingOutbox.map((entry) => ({
|
|
658
788
|
id: entry.id,
|
|
@@ -670,6 +800,15 @@ export class BridgeService {
|
|
|
670
800
|
(conflicts.length === 0 || offlineDevelopmentAllowed),
|
|
671
801
|
offlineDevelopmentOnly: offlineDevelopmentAllowed,
|
|
672
802
|
recoveredVerification,
|
|
803
|
+
...(checkout.git.baseCommit
|
|
804
|
+
? {
|
|
805
|
+
taskWorkCommitted: {
|
|
806
|
+
baseCommit: checkout.git.baseCommit,
|
|
807
|
+
headCommit: checkout.git.head,
|
|
808
|
+
branch: pointer?.branch ?? null,
|
|
809
|
+
},
|
|
810
|
+
}
|
|
811
|
+
: {}),
|
|
673
812
|
},
|
|
674
813
|
});
|
|
675
814
|
});
|
|
@@ -720,9 +859,44 @@ export class BridgeService {
|
|
|
720
859
|
conflicts: [...new Set(blockingConflicts)],
|
|
721
860
|
outbox,
|
|
722
861
|
repository: publicRepository(repository),
|
|
862
|
+
...(blockingConflicts.some((conflict) => contextRefreshConflicts.includes(conflict))
|
|
863
|
+
? { nextAction: contextRefreshAction(undefined, pointer.changeBaseline) }
|
|
864
|
+
: {}),
|
|
723
865
|
});
|
|
724
866
|
}
|
|
725
|
-
|
|
867
|
+
let activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
|
|
868
|
+
if (!activeBaseline) {
|
|
869
|
+
const snapshot = objectValue((await this.dependencies.client.request(endpoints.sessionResume, {
|
|
870
|
+
method: 'POST',
|
|
871
|
+
cacheKey: resumeCacheKey(input.sessionId, repository.repoFingerprint, 0),
|
|
872
|
+
allowStaleOnUnavailable: true,
|
|
873
|
+
body: {
|
|
874
|
+
sessionId: input.sessionId,
|
|
875
|
+
repoFingerprint: repository.repoFingerprint,
|
|
876
|
+
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
877
|
+
afterSequence: 0,
|
|
878
|
+
},
|
|
879
|
+
})).data);
|
|
880
|
+
const recordedBaselineDiffHash = typeof snapshot?.changeBaselineDiffHash === 'string'
|
|
881
|
+
? snapshot.changeBaselineDiffHash
|
|
882
|
+
: undefined;
|
|
883
|
+
if (recordedBaselineDiffHash) {
|
|
884
|
+
activeBaseline = repairChangeBaseline(undefined, recordedBaselineDiffHash, repository.git.head);
|
|
885
|
+
if (!activeBaseline) {
|
|
886
|
+
return asJsonValue({
|
|
887
|
+
editLeaseGranted: false,
|
|
888
|
+
conflicts: ['task_change_baseline_recaptured'],
|
|
889
|
+
outbox,
|
|
890
|
+
repository: publicRepository(repository),
|
|
891
|
+
nextAction: `This task started from baseline ${short(recordedBaselineDiffHash)}, which cannot be proved against the current commit, so a lease taken now would measure only part of the work. Say what happened and let the user decide between reopening the task and abandoning it.`,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
await this.dependencies.activeContexts.save({
|
|
895
|
+
...pointer,
|
|
896
|
+
changeBaseline: activeBaseline,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
726
900
|
const changedPaths = normalizeChangedPaths([
|
|
727
901
|
...(activeBaseline?.leasePaths ?? []),
|
|
728
902
|
...input.changedPaths,
|
|
@@ -750,7 +924,7 @@ export class BridgeService {
|
|
|
750
924
|
sessionId: input.sessionId,
|
|
751
925
|
changedPaths,
|
|
752
926
|
baselineDiffHash,
|
|
753
|
-
source: await this.checkoutSource(repository, pointer
|
|
927
|
+
source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
|
|
754
928
|
...(decision ? { branch: decision.branch } : {}),
|
|
755
929
|
...(input.transitionToWrite
|
|
756
930
|
? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
|
|
@@ -962,13 +1136,14 @@ export class BridgeService {
|
|
|
962
1136
|
if (pointer && repository && pointer.mode !== 'read_only')
|
|
963
1137
|
await this.assertPoolPointer(repository, pointer);
|
|
964
1138
|
const source = repository
|
|
965
|
-
? await this.checkoutSource(repository, pointer?.source)
|
|
1139
|
+
? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
|
|
966
1140
|
: undefined;
|
|
967
1141
|
const response = await this.dependencies.client.request(endpoints.contextRefresh, {
|
|
968
1142
|
method: 'POST',
|
|
969
1143
|
body: cleanJson({ sessionId: input.sessionId, source }),
|
|
970
1144
|
});
|
|
971
|
-
|
|
1145
|
+
for (const conflict of contextRefreshConflicts)
|
|
1146
|
+
await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, conflict);
|
|
972
1147
|
return asJsonValue({
|
|
973
1148
|
...objectOrEmpty(response.data),
|
|
974
1149
|
requiresPrepareChange: true,
|
|
@@ -984,7 +1159,9 @@ export class BridgeService {
|
|
|
984
1159
|
cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
|
|
985
1160
|
allowStaleOnUnavailable: true,
|
|
986
1161
|
});
|
|
987
|
-
return
|
|
1162
|
+
return Array.isArray(response.data)
|
|
1163
|
+
? asJsonValue({ resources: response.data })
|
|
1164
|
+
: response.data;
|
|
988
1165
|
});
|
|
989
1166
|
}
|
|
990
1167
|
async memoryHistory(input) {
|
|
@@ -992,15 +1169,31 @@ export class BridgeService {
|
|
|
992
1169
|
const body = cleanJson({
|
|
993
1170
|
sessionId: input.sessionId,
|
|
994
1171
|
resourceKeys: input.resourceKeys,
|
|
1172
|
+
resourceIds: input.resourceIds,
|
|
995
1173
|
paths: input.paths,
|
|
1174
|
+
required: input.required,
|
|
996
1175
|
depth: input.depth,
|
|
997
1176
|
});
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1177
|
+
let response;
|
|
1178
|
+
try {
|
|
1179
|
+
response = await this.dependencies.client.request(endpoints.memoryHistory, {
|
|
1180
|
+
method: 'POST',
|
|
1181
|
+
body,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
if (backendRejectsField(error, ['required', 'resourceIds']))
|
|
1186
|
+
throw refuse('This Engineering Memory server is older than this client and does not accept required or resourceIds in memory.history yet. Call memory.history with the resourceKeys or paths of the records this change touches.', 'memory.history');
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
const data = objectValue(response.data);
|
|
1002
1190
|
const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
|
|
1003
|
-
return asJsonValue({
|
|
1191
|
+
return asJsonValue({
|
|
1192
|
+
history: Array.isArray(response.data) ? response.data : (data?.histories ?? []),
|
|
1193
|
+
uncoveredPaths: data?.uncoveredPaths ?? [],
|
|
1194
|
+
historyReadsRequired: data?.historyReadsRequired ?? [],
|
|
1195
|
+
relatedBranches,
|
|
1196
|
+
});
|
|
1004
1197
|
});
|
|
1005
1198
|
}
|
|
1006
1199
|
async branchTaskReferences(repoRoot, paths) {
|
|
@@ -1341,7 +1534,7 @@ export class BridgeService {
|
|
|
1341
1534
|
async memoryListProposals(input) {
|
|
1342
1535
|
return await this.execute(async () => {
|
|
1343
1536
|
const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
|
|
1344
|
-
return asJsonValue(
|
|
1537
|
+
return asJsonValue(response.data);
|
|
1345
1538
|
});
|
|
1346
1539
|
}
|
|
1347
1540
|
async memoryReviewProposal(input) {
|
|
@@ -1416,16 +1609,25 @@ export class BridgeService {
|
|
|
1416
1609
|
}
|
|
1417
1610
|
return [...pending];
|
|
1418
1611
|
}
|
|
1612
|
+
ruleDeviationQuestions(input) {
|
|
1613
|
+
const language = input.language ?? 'en';
|
|
1614
|
+
return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
|
|
1615
|
+
definition: ruleDeviationQuestion(input.taskId, path, rule, language),
|
|
1616
|
+
previousDefinitions: ['en', 'tr']
|
|
1617
|
+
.filter((other) => other !== language)
|
|
1618
|
+
.map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
|
|
1619
|
+
})));
|
|
1620
|
+
}
|
|
1419
1621
|
async taskSelfReview(input) {
|
|
1420
1622
|
return await this.execute(async () => {
|
|
1421
|
-
const
|
|
1422
|
-
assertSafeToPersist(cleanJson({
|
|
1423
|
-
const
|
|
1623
|
+
const files = await this.approvedDeviations(input);
|
|
1624
|
+
assertSafeToPersist(cleanJson({ files }));
|
|
1625
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1626
|
+
const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
|
|
1424
1627
|
if (this.deferDeliveries) {
|
|
1425
1628
|
return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
|
|
1426
1629
|
diffHash: repository.git.diffHash,
|
|
1427
|
-
|
|
1428
|
-
findings,
|
|
1630
|
+
files,
|
|
1429
1631
|
}));
|
|
1430
1632
|
}
|
|
1431
1633
|
const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
|
|
@@ -1435,20 +1637,50 @@ export class BridgeService {
|
|
|
1435
1637
|
taskId: input.taskId,
|
|
1436
1638
|
expectedTaskVersion: snapshot.taskVersion,
|
|
1437
1639
|
diffHash: repository.git.diffHash,
|
|
1438
|
-
|
|
1439
|
-
findings,
|
|
1640
|
+
files,
|
|
1440
1641
|
}),
|
|
1441
1642
|
});
|
|
1442
1643
|
await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
|
|
1443
1644
|
return asJsonValue({
|
|
1444
1645
|
selfReview: {
|
|
1445
1646
|
diffHash: repository.git.diffHash,
|
|
1446
|
-
|
|
1447
|
-
|
|
1647
|
+
files: files.map(({ path, rules }) => ({
|
|
1648
|
+
path,
|
|
1649
|
+
rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
|
|
1650
|
+
})),
|
|
1448
1651
|
},
|
|
1449
1652
|
});
|
|
1450
1653
|
});
|
|
1451
1654
|
}
|
|
1655
|
+
async approvedDeviations(input) {
|
|
1656
|
+
const files = reviewedFiles(input);
|
|
1657
|
+
if (!files.some(({ rules }) => rules.some(isDeviation)))
|
|
1658
|
+
return files;
|
|
1659
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
1660
|
+
return await Promise.all(files.map(async ({ path, rules }) => ({
|
|
1661
|
+
path,
|
|
1662
|
+
rules: await Promise.all(rules.map(async (rule) => {
|
|
1663
|
+
if (!isDeviation(rule))
|
|
1664
|
+
return rule;
|
|
1665
|
+
const questionnaireId = ruleDeviationQuestionnaireId(input.taskId, path, rule);
|
|
1666
|
+
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
1667
|
+
const choice = question?.status === 'answered' ? question.answer?.choice : undefined;
|
|
1668
|
+
if (choice === 'change')
|
|
1669
|
+
throw refuse(`The user chose to change ${path} so it follows ${rule.resourceKey}. Change the code, then record task.self_review again with that rule as fixed.`, 'task.self_review');
|
|
1670
|
+
if (choice !== 'approve' || !question?.answeredAt)
|
|
1671
|
+
throw refuse(`The user has not answered whether ${path} may keep deviating from ${rule.resourceKey}. Call the task.self_review tool so it asks the rule-deviation question natively, then retry.`, 'task.self_review');
|
|
1672
|
+
return {
|
|
1673
|
+
...rule,
|
|
1674
|
+
questionnaireId,
|
|
1675
|
+
deviationApproval: {
|
|
1676
|
+
questionnaireId,
|
|
1677
|
+
answeredAt: question.answeredAt,
|
|
1678
|
+
hostTool: question.answerSource?.hostTool ?? null,
|
|
1679
|
+
},
|
|
1680
|
+
};
|
|
1681
|
+
})),
|
|
1682
|
+
})));
|
|
1683
|
+
}
|
|
1452
1684
|
async taskReconcile(input) {
|
|
1453
1685
|
return await this.execute(async () => {
|
|
1454
1686
|
const repoRoot = input.repoRoot ?? process.cwd();
|
|
@@ -1567,7 +1799,7 @@ export class BridgeService {
|
|
|
1567
1799
|
method: 'POST',
|
|
1568
1800
|
body: cleanJson({
|
|
1569
1801
|
sessionId: pointer.sessionId,
|
|
1570
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
1802
|
+
source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
|
|
1571
1803
|
}),
|
|
1572
1804
|
});
|
|
1573
1805
|
const refreshData = objectValue(refreshed.data);
|
|
@@ -1592,13 +1824,18 @@ export class BridgeService {
|
|
|
1592
1824
|
}
|
|
1593
1825
|
async taskVerify(input) {
|
|
1594
1826
|
return await this.execute(async () => {
|
|
1827
|
+
const timer = startPhaseTimer('task.verify');
|
|
1595
1828
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1596
1829
|
await this.recoverJournalOutbox();
|
|
1830
|
+
timer.mark('recover_journal_outbox');
|
|
1597
1831
|
const outbox = await this.flushOutbox(input.taskId);
|
|
1832
|
+
timer.mark('flush_outbox');
|
|
1598
1833
|
const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
|
|
1599
1834
|
.length;
|
|
1600
|
-
|
|
1835
|
+
let repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1836
|
+
timer.mark('resolve_repository');
|
|
1601
1837
|
if (pendingOutboxCount > 0) {
|
|
1838
|
+
timer.finish();
|
|
1602
1839
|
return asJsonValue({
|
|
1603
1840
|
verified: false,
|
|
1604
1841
|
reason: 'pending_outbox',
|
|
@@ -1608,6 +1845,7 @@ export class BridgeService {
|
|
|
1608
1845
|
});
|
|
1609
1846
|
}
|
|
1610
1847
|
const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
|
|
1848
|
+
repository = await this.taskRepository(repository, pointer);
|
|
1611
1849
|
const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
|
|
1612
1850
|
if (pendingCorrectionScopes.length > 0) {
|
|
1613
1851
|
return asJsonValue({
|
|
@@ -1640,7 +1878,7 @@ export class BridgeService {
|
|
|
1640
1878
|
body: {
|
|
1641
1879
|
sessionId: input.sessionId,
|
|
1642
1880
|
repoFingerprint: repository.repoFingerprint,
|
|
1643
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
1881
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1644
1882
|
afterSequence: 0,
|
|
1645
1883
|
},
|
|
1646
1884
|
});
|
|
@@ -1661,6 +1899,10 @@ export class BridgeService {
|
|
|
1661
1899
|
await this.assertTaskWorktree(repository, snapshotTask, pointer);
|
|
1662
1900
|
const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
|
|
1663
1901
|
const validations = normalizePersistentInput(input.validations, repository.repoRoot);
|
|
1902
|
+
const waivers = normalizePersistentInput(input.waivers ?? [], repository.repoRoot);
|
|
1903
|
+
if (snapshot?.requiresContextRefresh === true) {
|
|
1904
|
+
throw refuse(contextRefreshAction(snapshot.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
1905
|
+
}
|
|
1664
1906
|
if (taskMode === 'read_only') {
|
|
1665
1907
|
if (input.leaseId || input.changedPaths?.length) {
|
|
1666
1908
|
throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
|
|
@@ -1668,48 +1910,114 @@ export class BridgeService {
|
|
|
1668
1910
|
}
|
|
1669
1911
|
else {
|
|
1670
1912
|
const baseline = pointer.changeBaseline;
|
|
1671
|
-
const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
|
|
1672
|
-
? activeLease.changedPaths.filter((path) => typeof path === 'string')
|
|
1673
|
-
: [];
|
|
1674
1913
|
if (!baseline) {
|
|
1675
1914
|
throw refuse('This task has no local change baseline, so what it changed cannot be measured. The baseline is restored from the task record.', 'session.resume');
|
|
1676
1915
|
}
|
|
1677
|
-
if (activeLease
|
|
1678
|
-
throw refuse(`
|
|
1916
|
+
if (!activeLease) {
|
|
1917
|
+
throw refuse(`There is no active lease (the last one was consumed or expired). Call context.prepare_change for ${baseline.leasePaths.join(', ') || 'the paths this task changes'}; the task baseline stays ${short(baseline.diffHash)}.`, 'context.prepare_change');
|
|
1918
|
+
}
|
|
1919
|
+
if (activeLease.baselineDiffHash !== baseline.diffHash) {
|
|
1920
|
+
throw refuse(`The active lease was taken against baseline ${short(activeLease.baselineDiffHash)} but this task started from ${short(baseline.diffHash)}, so the lease must be taken again against the task's own baseline.`, 'context.prepare_change');
|
|
1679
1921
|
}
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
.filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
|
|
1922
|
+
const changeSet = taskChangeSet(baseline, activeLease, repository.git.changedPaths);
|
|
1923
|
+
taskChanges = changeSet.taskChanges;
|
|
1924
|
+
foreignChangedPaths = changeSet.wholeTreeDelta
|
|
1925
|
+
.filter((entry) => !changeSet.leased.has(normalizeChangedPaths([entry.path])[0]))
|
|
1685
1926
|
.map((entry) => entry.path)
|
|
1686
1927
|
.sort();
|
|
1687
1928
|
taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1929
|
+
const reported = input.changedPaths
|
|
1930
|
+
? normalizeChangedPaths(input.changedPaths)
|
|
1931
|
+
: taskChangedPaths;
|
|
1932
|
+
const treePaths = new Set(normalizeChangedPaths(changeSet.wholeTreeDelta.map((entry) => entry.path)));
|
|
1933
|
+
const outsideLease = [
|
|
1934
|
+
...new Set([
|
|
1935
|
+
...reported.filter((path) => treePaths.has(path) && !changeSet.leased.has(path)),
|
|
1936
|
+
...taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
|
|
1937
|
+
!pathsContainAll(changeSet.leaseChangedPaths, [path])),
|
|
1938
|
+
]),
|
|
1939
|
+
].sort();
|
|
1940
|
+
const notChanged = reported.filter((path) => !treePaths.has(path));
|
|
1941
|
+
const unreported = taskChangedPaths.filter((path) => !reported.includes(path));
|
|
1942
|
+
const baselineMoved = typeof snapshot?.changeBaselineDiffHash === 'string' &&
|
|
1943
|
+
snapshot.changeBaselineDiffHash !== baseline.diffHash;
|
|
1944
|
+
const pathFindings = [
|
|
1945
|
+
...(outsideLease.length > 0
|
|
1946
|
+
? [
|
|
1947
|
+
`These changed paths are outside the current lease: ${samplePaths(outsideLease)}. Call context.prepare_change with them; the lease expands and the task baseline stays ${short(baseline.diffHash)}.`,
|
|
1948
|
+
]
|
|
1949
|
+
: []),
|
|
1950
|
+
...(notChanged.length > 0
|
|
1951
|
+
? [
|
|
1952
|
+
`These reported paths do not differ from the tree this task started from (baseline ${short(baseline.diffHash)}): ${samplePaths(notChanged)}. ${baselineMoved
|
|
1953
|
+
? 'The baseline has moved from the one the task recorded; resuming repairs it.'
|
|
1954
|
+
: 'Report only the paths the manifest lists.'}`,
|
|
1955
|
+
]
|
|
1956
|
+
: []),
|
|
1957
|
+
...(unreported.length > 0
|
|
1958
|
+
? [
|
|
1959
|
+
`This task changed these paths but did not report them: ${samplePaths(unreported)}. Report every path the manifest lists.`,
|
|
1960
|
+
]
|
|
1961
|
+
: []),
|
|
1962
|
+
];
|
|
1963
|
+
if (pathFindings.length > 0) {
|
|
1964
|
+
throw refuse(pathFindings.join(' '), outsideLease.length > 0
|
|
1965
|
+
? 'context.prepare_change'
|
|
1966
|
+
: notChanged.length > 0 && baselineMoved
|
|
1967
|
+
? 'session.resume'
|
|
1968
|
+
: 'task.verify', {
|
|
1969
|
+
outsideLease,
|
|
1970
|
+
notChanged,
|
|
1971
|
+
unreported,
|
|
1972
|
+
changedPathsOutsideThisTask: foreignChangedPaths,
|
|
1973
|
+
});
|
|
1692
1974
|
}
|
|
1693
1975
|
if (taskChangedPaths.length === 0) {
|
|
1694
1976
|
throw refuse('The manifest shows this task changed nothing. Either the work is not in the working tree, or the baseline has moved to equal it.', 'session.resume');
|
|
1695
1977
|
}
|
|
1696
|
-
if (!
|
|
1697
|
-
throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease
|
|
1698
|
-
}
|
|
1699
|
-
const unleased = taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
|
|
1700
|
-
!pathsContainAll(leaseChangedPaths, [path]));
|
|
1701
|
-
if (unleased.length > 0) {
|
|
1702
|
-
throw refuse(`These changed paths are outside the current lease: ${unleased.slice(0, 5).join(', ')}${unleased.length > 5 ? `, and ${unleased.length - 5} more` : ''}. The lease must cover every path this task changed.`, 'context.prepare_change');
|
|
1978
|
+
if (!input.leaseId || activeLease.id !== input.leaseId) {
|
|
1979
|
+
throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
|
|
1703
1980
|
}
|
|
1704
|
-
assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
|
|
1705
1981
|
}
|
|
1706
1982
|
assertSafeToPersist(cleanJson(validations));
|
|
1707
|
-
|
|
1983
|
+
assertSafeToPersist(cleanJson(waivers));
|
|
1984
|
+
const evidenceFindings = [
|
|
1985
|
+
...(taskMode === 'read_only'
|
|
1986
|
+
? []
|
|
1987
|
+
: newResourceEvidenceFindings(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? [])),
|
|
1988
|
+
...validationEvidenceFindings(validations),
|
|
1989
|
+
];
|
|
1990
|
+
if (evidenceFindings.length > 0) {
|
|
1991
|
+
throw refuse(evidenceFindings.length === 1
|
|
1992
|
+
? evidenceFindings[0]
|
|
1993
|
+
: `Verification found ${evidenceFindings.length} unmet requirements: ${evidenceFindings
|
|
1994
|
+
.map((finding, index) => `(${index + 1}) ${finding}`)
|
|
1995
|
+
.join(' ')}`, 'task.verify', { findings: evidenceFindings });
|
|
1996
|
+
}
|
|
1997
|
+
const validationWaivers = [];
|
|
1998
|
+
if (waivers.length > 0) {
|
|
1999
|
+
const paths = renderedPaths(taskChangedPaths, resourceDiscoveryPolicy);
|
|
2000
|
+
const scope = await this.questionnaireScope(repository.repoRoot);
|
|
2001
|
+
for (const waiver of waivers) {
|
|
2002
|
+
const questionnaireId = validationWaiverId(input.taskId, waiver.validationId, paths, waiver.reason);
|
|
2003
|
+
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
2004
|
+
if (question?.answer?.choice !== 'waive') {
|
|
2005
|
+
throw refuse(`The user has not agreed to skip the ${waiver.validationId} check for ${samplePaths(paths) || 'this change'} with this reason. Call task.verify with the waiver so the tool asks them, or provide the evidence.`, 'task.verify');
|
|
2006
|
+
}
|
|
2007
|
+
validationWaivers.push({
|
|
2008
|
+
validationId: waiver.validationId,
|
|
2009
|
+
questionnaireId,
|
|
2010
|
+
reason: waiver.reason,
|
|
2011
|
+
paths,
|
|
2012
|
+
answerSource: question.answerSource?.hostTool ?? 'mcp_form',
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1708
2016
|
const pathChangeManifest = taskMode === 'read_only'
|
|
1709
2017
|
? []
|
|
1710
2018
|
: await buildPathChangeManifest(repository.repoRoot, taskChanges);
|
|
1711
2019
|
const verifyBody = cleanJson({
|
|
1712
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
2020
|
+
source: await this.checkoutSource(repository, pointer.source, pointer),
|
|
1713
2021
|
taskId: input.taskId,
|
|
1714
2022
|
sessionId: input.sessionId,
|
|
1715
2023
|
expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
|
|
@@ -1729,6 +2037,7 @@ export class BridgeService {
|
|
|
1729
2037
|
: {}),
|
|
1730
2038
|
...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
|
|
1731
2039
|
validations,
|
|
2040
|
+
...(validationWaivers.length > 0 ? { validationWaivers } : {}),
|
|
1732
2041
|
pendingOutboxCount,
|
|
1733
2042
|
newResources: input.newResources,
|
|
1734
2043
|
introducedStructures: input.introducedStructures,
|
|
@@ -1749,6 +2058,8 @@ export class BridgeService {
|
|
|
1749
2058
|
if (!isBackendUnavailableError(error)) {
|
|
1750
2059
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1751
2060
|
}
|
|
2061
|
+
if (backendRejectsField(error, ['validationWaivers']))
|
|
2062
|
+
throw refuse('This Engineering Memory server is older than this client and cannot record a validation waiver yet, so the task cannot verify on the waiver the user gave. Provide ui evidence that exercises the changed surface and call task.verify without waivers.', 'task.verify');
|
|
1752
2063
|
throw error;
|
|
1753
2064
|
}
|
|
1754
2065
|
const responseData = objectValue(response.data);
|
|
@@ -1772,6 +2083,7 @@ export class BridgeService {
|
|
|
1772
2083
|
else {
|
|
1773
2084
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1774
2085
|
}
|
|
2086
|
+
timer.finish();
|
|
1775
2087
|
return asJsonValue({
|
|
1776
2088
|
...objectOrEmpty(response.data),
|
|
1777
2089
|
mode: taskMode,
|
|
@@ -1783,11 +2095,45 @@ export class BridgeService {
|
|
|
1783
2095
|
});
|
|
1784
2096
|
});
|
|
1785
2097
|
}
|
|
2098
|
+
async validationWaiverSubject(input) {
|
|
2099
|
+
return await this.execute(async () => {
|
|
2100
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
2101
|
+
const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
|
|
2102
|
+
if (!pointer.changeBaseline) {
|
|
2103
|
+
throw refuse('This task has no local change baseline, so what it changed cannot be measured. The baseline is restored from the task record.', 'session.resume');
|
|
2104
|
+
}
|
|
2105
|
+
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
2106
|
+
method: 'POST',
|
|
2107
|
+
body: {
|
|
2108
|
+
sessionId: input.sessionId,
|
|
2109
|
+
repoFingerprint: repository.repoFingerprint,
|
|
2110
|
+
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2111
|
+
afterSequence: 0,
|
|
2112
|
+
},
|
|
2113
|
+
});
|
|
2114
|
+
const snapshot = objectValue(response.data);
|
|
2115
|
+
const activeLease = objectValue(snapshot?.activeLease);
|
|
2116
|
+
const paths = renderedPaths(taskChangeSet(pointer.changeBaseline, activeLease, repository.git.changedPaths).taskChanges.map((entry) => entry.path), readResourceDiscoveryPolicy(snapshot, activeLease));
|
|
2117
|
+
if (paths.length === 0) {
|
|
2118
|
+
throw refuse(`No path this task changed renders UI, so there is no ${input.validationId} check to waive. Call task.verify without the waiver.`, 'task.verify');
|
|
2119
|
+
}
|
|
2120
|
+
const reason = normalizePersistentInput(input.reason, repository.repoRoot);
|
|
2121
|
+
return asJsonValue({
|
|
2122
|
+
taskId: input.taskId,
|
|
2123
|
+
externalTaskId: pointer.taskSlug,
|
|
2124
|
+
validationId: input.validationId,
|
|
2125
|
+
paths,
|
|
2126
|
+
reason,
|
|
2127
|
+
questionnaireId: validationWaiverId(input.taskId, input.validationId, paths, reason),
|
|
2128
|
+
});
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
1786
2131
|
async taskClose(input) {
|
|
1787
2132
|
return await this.execute(async () => {
|
|
1788
2133
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1789
|
-
const
|
|
1790
|
-
const pointer = await this.requireActivePointer(
|
|
2134
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
2135
|
+
const pointer = await this.requireActivePointer(checkout.repoFingerprint, input.taskId, false, 'close');
|
|
2136
|
+
const repository = await this.taskRepository(checkout, pointer);
|
|
1791
2137
|
if (pointer.closeIntent) {
|
|
1792
2138
|
return await this.retryCloseIntent(repository, pointer);
|
|
1793
2139
|
}
|
|
@@ -1796,7 +2142,7 @@ export class BridgeService {
|
|
|
1796
2142
|
body: {
|
|
1797
2143
|
sessionId: pointer.sessionId,
|
|
1798
2144
|
repoFingerprint: repository.repoFingerprint,
|
|
1799
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2145
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1800
2146
|
afterSequence: 0,
|
|
1801
2147
|
},
|
|
1802
2148
|
});
|
|
@@ -1810,7 +2156,7 @@ export class BridgeService {
|
|
|
1810
2156
|
: [];
|
|
1811
2157
|
await this.assertTaskWorktree(repository, task, pointer);
|
|
1812
2158
|
const closeBody = {
|
|
1813
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2159
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1814
2160
|
taskId: input.taskId,
|
|
1815
2161
|
sessionId: pointer.sessionId,
|
|
1816
2162
|
expectedTaskVersion: numericTaskVersion(task.lockVersion),
|
|
@@ -1831,7 +2177,7 @@ export class BridgeService {
|
|
|
1831
2177
|
}
|
|
1832
2178
|
async worktreeTaskAllocation(input) {
|
|
1833
2179
|
return this.execute(async () => {
|
|
1834
|
-
const repository = await this.dependencies.repositories.
|
|
2180
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
1835
2181
|
const allocation = repository.projectId && this.dependencies.worktreePool
|
|
1836
2182
|
? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
|
|
1837
2183
|
: null;
|
|
@@ -1840,18 +2186,68 @@ export class BridgeService {
|
|
|
1840
2186
|
}
|
|
1841
2187
|
async projectGitPreferences(input) {
|
|
1842
2188
|
return this.execute(async () => {
|
|
1843
|
-
const repository = await this.dependencies.repositories.
|
|
2189
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd(), input.projectId);
|
|
1844
2190
|
if (!repository.projectId)
|
|
1845
2191
|
throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
|
|
1846
2192
|
const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
|
|
1847
2193
|
return asJsonValue({
|
|
1848
2194
|
...objectOrEmpty(objectValue(response.data)),
|
|
1849
2195
|
repoRoot: repository.repoRoot,
|
|
1850
|
-
currentBranch:
|
|
2196
|
+
currentBranch: repository.branch,
|
|
2197
|
+
currentCommit: repository.head,
|
|
2198
|
+
});
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
async taskBranchPreflight(input) {
|
|
2202
|
+
return this.execute(async () => {
|
|
2203
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
2204
|
+
if (!repository.projectId)
|
|
2205
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
2206
|
+
const existingAllocation = this.dependencies.worktreePool
|
|
2207
|
+
? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
|
|
2208
|
+
: null;
|
|
2209
|
+
if (existingAllocation)
|
|
2210
|
+
return asJsonValue({ existingAllocation });
|
|
2211
|
+
if (input.name && !input.keepCurrent) {
|
|
2212
|
+
const unusable = await this.unusableBranchName(repository.repoRoot, input.name);
|
|
2213
|
+
if (unusable)
|
|
2214
|
+
throw refuse(unusable, 'task.branch');
|
|
2215
|
+
}
|
|
2216
|
+
const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
|
|
2217
|
+
this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
|
|
2218
|
+
this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
2219
|
+
this.folderState(repository, input.externalTaskId),
|
|
2220
|
+
this.suggestedBranchName(repository.repoRoot, input.externalTaskId),
|
|
2221
|
+
]);
|
|
2222
|
+
return asJsonValue({
|
|
2223
|
+
existingAllocation: null,
|
|
2224
|
+
preferences: preferences.data,
|
|
2225
|
+
currentBranch,
|
|
1851
2226
|
currentCommit: repository.git.head,
|
|
2227
|
+
folder,
|
|
2228
|
+
suggestedName,
|
|
1852
2229
|
});
|
|
1853
2230
|
});
|
|
1854
2231
|
}
|
|
2232
|
+
async suggestedBranchName(repoRoot, externalTaskId) {
|
|
2233
|
+
const candidates = suggestedBranchNames(externalTaskId);
|
|
2234
|
+
for (const candidate of candidates)
|
|
2235
|
+
if (!(await this.unusableBranchName(repoRoot, candidate)))
|
|
2236
|
+
return candidate;
|
|
2237
|
+
return candidates[0];
|
|
2238
|
+
}
|
|
2239
|
+
async unusableBranchName(repoRoot, name) {
|
|
2240
|
+
const git = this.dependencies.repositories.git;
|
|
2241
|
+
try {
|
|
2242
|
+
await git.validateBranch(repoRoot, name);
|
|
2243
|
+
}
|
|
2244
|
+
catch {
|
|
2245
|
+
return `${name} is not a valid Git branch name, so no task branch was created.`;
|
|
2246
|
+
}
|
|
2247
|
+
return (await git.branchExists(repoRoot, name))
|
|
2248
|
+
? `Branch ${name} already exists, so it cannot be created for a new task; nothing was created.`
|
|
2249
|
+
: null;
|
|
2250
|
+
}
|
|
1855
2251
|
async setProjectGitPreferences(projectId, body) {
|
|
1856
2252
|
return this.execute(async () => {
|
|
1857
2253
|
assertSafeToPersist(body);
|
|
@@ -1887,18 +2283,47 @@ export class BridgeService {
|
|
|
1887
2283
|
}
|
|
1888
2284
|
async worktreeOperation(operation, input) {
|
|
1889
2285
|
return this.execute(async () => {
|
|
1890
|
-
const
|
|
2286
|
+
const timer = startPhaseTimer(`worktree.${operation}`);
|
|
2287
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
2288
|
+
timer.mark('resolve_identity');
|
|
1891
2289
|
const projectId = repository.projectId;
|
|
1892
2290
|
const pool = this.dependencies.worktreePool;
|
|
1893
2291
|
if (!projectId || !pool)
|
|
1894
2292
|
throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
|
|
1895
2293
|
if (operation === 'list' || operation === 'reconcile') {
|
|
1896
2294
|
const managedRoot = await pool.managedRoot();
|
|
2295
|
+
const policy = await this.poolPolicy();
|
|
2296
|
+
timer.mark('policy');
|
|
2297
|
+
const reconciled = await pool.reconcile(projectId, repository.repoRoot, policy);
|
|
2298
|
+
timer.mark('reconcile');
|
|
2299
|
+
const items = reconciled.map((item) => ({
|
|
2300
|
+
...item,
|
|
2301
|
+
...(item.activity === 'retired'
|
|
2302
|
+
? {
|
|
2303
|
+
nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
|
|
2304
|
+
}
|
|
2305
|
+
: unreadableWorktree(item)
|
|
2306
|
+
? {
|
|
2307
|
+
nextAction: `This checkout can no longer be read as a Git worktree. worktree.reconcile with forgetUnreadable {"externalTaskId": "${item.externalTaskId}"} asks the user whether Engineering Memory stops tracking it; no file in the folder is touched.`,
|
|
2308
|
+
}
|
|
2309
|
+
: item.phase === 'delivery'
|
|
2310
|
+
? {
|
|
2311
|
+
nextAction: 'Ask the user whether it was delivered; call worktree.release with deliveryOutcome delivered or cancelled (the product asks the user what happens to the folder).',
|
|
2312
|
+
}
|
|
2313
|
+
: !item.managed && item.phase !== 'released'
|
|
2314
|
+
? {
|
|
2315
|
+
nextAction: 'The user’s own folder, held by this task. Resume the task here to continue it. For another task here, task.branch asks whether this one should move out; a closed or abandoned task is let go when the next task starts here.',
|
|
2316
|
+
}
|
|
2317
|
+
: {}),
|
|
2318
|
+
}));
|
|
2319
|
+
const retired = items.some((item) => item.activity === 'retired');
|
|
2320
|
+
const selected = await pool.forPath(projectId, repository.repoRoot);
|
|
2321
|
+
timer.finish();
|
|
1897
2322
|
return asJsonValue({
|
|
1898
|
-
items
|
|
1899
|
-
selected
|
|
2323
|
+
items,
|
|
2324
|
+
selected,
|
|
1900
2325
|
managedRoot,
|
|
1901
|
-
...(managedRoot.problem || managedRoot.synced
|
|
2326
|
+
...(managedRoot.problem || managedRoot.synced || retired
|
|
1902
2327
|
? {
|
|
1903
2328
|
notice: [
|
|
1904
2329
|
...(managedRoot.problem ? [managedRoot.problem] : []),
|
|
@@ -1907,6 +2332,11 @@ export class BridgeService {
|
|
|
1907
2332
|
`Managed worktrees are created under ${managedRoot.root}, which a cloud sync client watches, so every Git operation there is re-scanned and uploaded. To create new worktrees elsewhere, write {"root": "<absolute directory>"} to ${managedRoot.override}; it takes effect for the next allocation, and existing worktrees keep working where they are.`,
|
|
1908
2333
|
]
|
|
1909
2334
|
: []),
|
|
2335
|
+
...(retired
|
|
2336
|
+
? [
|
|
2337
|
+
`Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
|
|
2338
|
+
]
|
|
2339
|
+
: []),
|
|
1910
2340
|
].join(' '),
|
|
1911
2341
|
}
|
|
1912
2342
|
: {}),
|
|
@@ -1934,29 +2364,132 @@ export class BridgeService {
|
|
|
1934
2364
|
await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
|
|
1935
2365
|
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1936
2366
|
}
|
|
2367
|
+
if (operation === 'forget_unreadable') {
|
|
2368
|
+
const entry = input.externalTaskId
|
|
2369
|
+
? await pool.forSlug(projectId, input.externalTaskId)
|
|
2370
|
+
: null;
|
|
2371
|
+
if (!entry || entry.generation !== input.generation || !input.decisionId)
|
|
2372
|
+
throw refuse('The unreadable checkout changed after it was asked about. Call worktree.list again before forgetting it.', 'worktree.list');
|
|
2373
|
+
if (input.decisionId !== forgetUnreadableQuestionId(entry, input.decisionAttempt ?? 0))
|
|
2374
|
+
throw refuse('The decision does not match this task and checkout. Repeat worktree.reconcile with forgetUnreadable.', 'worktree.reconcile');
|
|
2375
|
+
const decision = await this.questionnaireResume({
|
|
2376
|
+
repoRoot: repository.repoRoot,
|
|
2377
|
+
questionnaireId: input.decisionId,
|
|
2378
|
+
});
|
|
2379
|
+
if (decision.status !== 'answered' || decision.answer?.choice !== 'forget')
|
|
2380
|
+
throw refuse('A checkout stops being tracked only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
|
|
2381
|
+
const forgotten = await pool.forgetUnreadable(projectId, entry.externalTaskId, entry.generation);
|
|
2382
|
+
this.poolAllocations.delete(forgotten.repoRoot);
|
|
2383
|
+
const folderPresent = await lstat(forgotten.repoRoot).then(() => true, () => false);
|
|
2384
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(forgotten.repoFingerprint, forgotten.externalTaskId);
|
|
2385
|
+
if (pointer?.worktreeId === sha256(forgotten.repoRoot))
|
|
2386
|
+
await this.parkPointer(pointer);
|
|
2387
|
+
return asJsonValue({
|
|
2388
|
+
operation,
|
|
2389
|
+
complete: true,
|
|
2390
|
+
forgotten: {
|
|
2391
|
+
externalTaskId: forgotten.externalTaskId,
|
|
2392
|
+
repoRoot: forgotten.repoRoot,
|
|
2393
|
+
branch: forgotten.branch,
|
|
2394
|
+
pendingDelivery: forgotten.pendingDelivery,
|
|
2395
|
+
},
|
|
2396
|
+
filesTouched: false,
|
|
2397
|
+
folderPresent,
|
|
2398
|
+
nextAction: folderPresent
|
|
2399
|
+
? `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. Nothing in it was deleted or changed; the task itself stays open. Tell the user that its files are still on disk in that folder, and start its next worktree with task.branch when they want to continue.`
|
|
2400
|
+
: `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. The folder was already gone before this call, so anything in it that was never committed is not on disk any more${forgotten.pendingDelivery ? '; this task closed with its delivery unsettled, so say that plainly' : ''}. Whatever was committed is still on${forgotten.branch ? ' branch ' + forgotten.branch : ' its branch'}. The task itself stays open; start its next worktree with task.branch when the user wants to continue.`,
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
if (operation === 'retire') {
|
|
2404
|
+
const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
|
|
2405
|
+
if (!entry ||
|
|
2406
|
+
entry.generation !== input.generation ||
|
|
2407
|
+
input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
|
|
2408
|
+
throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
|
|
2409
|
+
const decision = await this.questionnaireResume({
|
|
2410
|
+
repoRoot: repository.repoRoot,
|
|
2411
|
+
questionnaireId: input.decisionId,
|
|
2412
|
+
});
|
|
2413
|
+
if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
|
|
2414
|
+
throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
|
|
2415
|
+
await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
|
|
2416
|
+
return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
|
|
2417
|
+
}
|
|
1937
2418
|
const entry = await pool.forPath(projectId, repository.repoRoot);
|
|
1938
2419
|
if (!entry ||
|
|
1939
2420
|
entry.externalTaskId !== input.externalTaskId ||
|
|
1940
2421
|
entry.generation !== input.generation)
|
|
1941
2422
|
throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
|
|
2423
|
+
if (operation === 'pause' && input.delivered !== undefined)
|
|
2424
|
+
throw refuse('task.pause keeps a task and its folder and records no delivery. After delivery, or when the user decided not to deliver, call worktree.release with deliveryOutcome delivered or cancelled.', 'worktree.release');
|
|
2425
|
+
const outcome = input.deliveryOutcome ?? (input.delivered === true ? 'delivered' : undefined);
|
|
2426
|
+
if (operation === 'release' && outcome === 'cancelled')
|
|
2427
|
+
throw refuse('A cancelled delivery is settled through the native question that worktree.release with deliveryOutcome cancelled asks.', 'worktree.release');
|
|
1942
2428
|
const perform = async () => {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2429
|
+
try {
|
|
2430
|
+
if (operation === 'heartbeat') {
|
|
2431
|
+
await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
|
|
2432
|
+
}
|
|
2433
|
+
if (operation === 'pause') {
|
|
2434
|
+
await pool.pause(projectId, repository.repoRoot, input.generation);
|
|
2435
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2436
|
+
}
|
|
2437
|
+
if (operation === 'release') {
|
|
2438
|
+
await this.requireDeliveredTaskWork(entry);
|
|
2439
|
+
await pool.release(projectId, repository.repoRoot, input.generation, outcome === 'delivered' ? outcome : undefined);
|
|
2440
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2441
|
+
}
|
|
2442
|
+
if (operation === 'cancel_delivery') {
|
|
2443
|
+
if (!entry.pendingDelivery)
|
|
2444
|
+
throw refuse('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
|
|
2445
|
+
const manifest = await this.dependencies.repositories.git.manifest(repository.repoRoot);
|
|
2446
|
+
const clean = manifest.changedPaths.length === 0;
|
|
2447
|
+
if (input.decisionId !== cancelledDeliveryQuestionId(entry, clean))
|
|
2448
|
+
throw refuse('The folder changed after the question was asked. Call worktree.release with deliveryOutcome cancelled again; it asks about the folder as it is now.', 'worktree.release');
|
|
2449
|
+
const decision = await this.questionnaireResume({
|
|
2450
|
+
repoRoot: repository.repoRoot,
|
|
2451
|
+
questionnaireId: input.decisionId,
|
|
2452
|
+
});
|
|
2453
|
+
const choice = decision.answer?.choice ?? '';
|
|
2454
|
+
const choices = clean || !entry.managed ? ['release', 'keep'] : ['save_and_release', 'keep'];
|
|
2455
|
+
if (decision.status !== 'answered' || !choices.includes(choice))
|
|
2456
|
+
throw refuse('Ask the user what happens to the folder in the native worktree.release form.', 'worktree.release');
|
|
2457
|
+
if (choice !== 'keep')
|
|
2458
|
+
await this.requireDeliveredTaskWork(entry);
|
|
2459
|
+
const cancelled = await pool.cancelDelivery(projectId, repository.repoRoot, input.generation, choice);
|
|
2460
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2461
|
+
return asJsonValue({
|
|
2462
|
+
operation,
|
|
2463
|
+
complete: true,
|
|
2464
|
+
repoRoot: repository.repoRoot,
|
|
2465
|
+
deliveryOutcome: 'cancelled',
|
|
2466
|
+
...cancelled,
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1948
2470
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
await this.flushOutbox(entry.taskId);
|
|
1952
|
-
await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
|
|
1953
|
-
this.poolAllocations.delete(repository.repoRoot);
|
|
2471
|
+
finally {
|
|
2472
|
+
timer.finish();
|
|
1954
2473
|
}
|
|
1955
|
-
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1956
2474
|
};
|
|
1957
2475
|
return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
|
|
1958
2476
|
});
|
|
1959
2477
|
}
|
|
2478
|
+
async requireDeliveredTaskWork(entry) {
|
|
2479
|
+
if (!entry.taskId)
|
|
2480
|
+
return;
|
|
2481
|
+
await this.flushOutbox(entry.taskId);
|
|
2482
|
+
if (!entry.managed)
|
|
2483
|
+
return;
|
|
2484
|
+
const queued = await this.dependencies.outbox.listForTask(entry.taskId);
|
|
2485
|
+
if (queued.length)
|
|
2486
|
+
throw refuse(`Task ${entry.externalTaskId} still has ${queued.length} record(s) in the local outbox that the backend has not accepted: ${queued
|
|
2487
|
+
.map((item) => `${item.id} (${item.operation}${item.lastError ? ', ' + item.lastError : ''})`)
|
|
2488
|
+
.join(', ')}. The folder was left as it is. Inspect each with task.resolve_pending_delivery and retry or discard it, then call worktree.release again.`, 'task.resolve_pending_delivery');
|
|
2489
|
+
const pointer = await this.dependencies.activeContexts.loadForTask(entry.repoFingerprint, entry.taskId);
|
|
2490
|
+
if (pointer?.verificationIntent || pointer?.closeIntent)
|
|
2491
|
+
throw refuse(`Task ${entry.externalTaskId} has an interrupted ${pointer.closeIntent ? 'close' : 'verification'}. The folder was left as it is. Call session.resume in this folder to finish it, then call worktree.release again.`, 'session.resume');
|
|
2492
|
+
}
|
|
1960
2493
|
async pooledTaskBranch(repository, input) {
|
|
1961
2494
|
const pool = this.dependencies.worktreePool;
|
|
1962
2495
|
const projectId = repository.projectId;
|
|
@@ -1967,24 +2500,72 @@ export class BridgeService {
|
|
|
1967
2500
|
if (existing)
|
|
1968
2501
|
source = { sourceCommit: existing.baseCommit };
|
|
1969
2502
|
else {
|
|
1970
|
-
if (!input.decisionId || !input.base)
|
|
2503
|
+
if (!input.decisionId || !input.startId || !input.base)
|
|
1971
2504
|
throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
|
|
1972
|
-
const
|
|
2505
|
+
const start = taskStartChoice(await this.questionnaireResume({
|
|
1973
2506
|
repoRoot: repository.repoRoot,
|
|
1974
|
-
questionnaireId: input.
|
|
1975
|
-
});
|
|
1976
|
-
const
|
|
2507
|
+
questionnaireId: input.startId,
|
|
2508
|
+
}));
|
|
2509
|
+
const request = {
|
|
1977
2510
|
externalTaskId: input.externalTaskId,
|
|
1978
2511
|
base: input.base,
|
|
1979
2512
|
name: input.name ?? null,
|
|
1980
2513
|
keepCurrent: input.keepCurrent === true,
|
|
1981
2514
|
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2515
|
+
};
|
|
2516
|
+
const started = start?.status === 'start' &&
|
|
2517
|
+
start.externalTaskId === request.externalTaskId &&
|
|
2518
|
+
start.decisionAttempt === input.decisionAttempt &&
|
|
2519
|
+
start.name === request.name &&
|
|
2520
|
+
start.keepCurrent === request.keepCurrent &&
|
|
2521
|
+
start.inPlace === (input.inPlace === true)
|
|
2522
|
+
? start
|
|
2523
|
+
: null;
|
|
2524
|
+
let approved = false;
|
|
2525
|
+
if (started && input.decisionId === input.startId)
|
|
2526
|
+
approved = stableStringify(started.base) === stableStringify(input.base);
|
|
2527
|
+
else if (started &&
|
|
2528
|
+
input.decisionId ===
|
|
2529
|
+
'task-branch-fallback-' +
|
|
2530
|
+
sha256(stableStringify({ ...request, inPlace: input.inPlace === true }))) {
|
|
2531
|
+
const fallback = await this.questionnaireResume({
|
|
2532
|
+
repoRoot: repository.repoRoot,
|
|
2533
|
+
questionnaireId: input.decisionId,
|
|
2534
|
+
});
|
|
2535
|
+
approved = fallback.status === 'answered' && fallback.answer?.choice === 'approve';
|
|
2536
|
+
}
|
|
2537
|
+
if (!approved)
|
|
2538
|
+
throw refuse('The task branch decision does not match the approved source, branch and location.', 'task.branch');
|
|
2539
|
+
const holder = input.inPlace
|
|
2540
|
+
? await this.folderHolder(repository, input.externalTaskId)
|
|
2541
|
+
: null;
|
|
1986
2542
|
source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
|
|
2543
|
+
if (holder && input.inPlace) {
|
|
2544
|
+
if (holder.managed || holder.running || !sameHolder(holder, started.holder))
|
|
2545
|
+
throw refuse(`This folder is held by task ${holder.externalTaskId}. Call task.branch again: its start form asks whether ${holder.externalTaskId} should move out of this folder before the new branch starts here, or opens the new task in a separate worktree.`, 'task.branch');
|
|
2546
|
+
if (repository.git.changedPaths.length > 0)
|
|
2547
|
+
throw refuse(`This folder has uncommitted changes, so the new branch cannot start here and ${holder.externalTaskId} stays in it. Call task.branch with a new decisionAttempt and open the task in a separate worktree.`, 'task.branch');
|
|
2548
|
+
if (await git.branchExists(repository.repoRoot, input.name))
|
|
2549
|
+
throw refuse(`Branch ${input.name} already exists, so it cannot start here and ${holder.externalTaskId} stays in this folder. Call task.branch with a new decisionAttempt and choose a new branch name.`, 'task.branch');
|
|
2550
|
+
if (holder.generation) {
|
|
2551
|
+
await pool.moveOut(projectId, repository.repoRoot, holder.generation, holder.oid ?? undefined);
|
|
2552
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2553
|
+
}
|
|
2554
|
+
else
|
|
2555
|
+
await git.branchStore(repository.repoRoot).detach({
|
|
2556
|
+
projectId: holder.projectId,
|
|
2557
|
+
externalTaskId: holder.externalTaskId,
|
|
2558
|
+
branch: holder.branch,
|
|
2559
|
+
...(holder.taskId ? { taskId: holder.taskId } : {}),
|
|
2560
|
+
}, holder.oid);
|
|
2561
|
+
const parked = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, holder.externalTaskId);
|
|
2562
|
+
if (parked?.worktreeId === sha256(repository.repoRoot))
|
|
2563
|
+
await this.parkPointer(parked);
|
|
2564
|
+
}
|
|
1987
2565
|
}
|
|
2566
|
+
const allocationTimer = startPhaseTimer('task.branch.allocate');
|
|
2567
|
+
const allocationPolicy = await this.poolPolicy(true);
|
|
2568
|
+
allocationTimer.mark('policy');
|
|
1988
2569
|
const allocation = await pool.allocate({
|
|
1989
2570
|
projectId,
|
|
1990
2571
|
projectName: preferences.projectName ?? projectId,
|
|
@@ -1994,8 +2575,11 @@ export class BridgeService {
|
|
|
1994
2575
|
branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
|
|
1995
2576
|
baseCommit: source.sourceCommit,
|
|
1996
2577
|
keepCurrent: input.keepCurrent,
|
|
2578
|
+
inPlace: !existing && input.inPlace,
|
|
1997
2579
|
resume: Boolean(existing),
|
|
1998
|
-
},
|
|
2580
|
+
}, allocationPolicy);
|
|
2581
|
+
allocationTimer.mark('pool_allocate');
|
|
2582
|
+
allocationTimer.finish();
|
|
1999
2583
|
this.poolAllocations.set(allocation.repoRoot, allocation);
|
|
2000
2584
|
if (existing && allocation.repoRoot === repository.repoRoot) {
|
|
2001
2585
|
const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
|
|
@@ -2067,8 +2651,9 @@ export class BridgeService {
|
|
|
2067
2651
|
(await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
|
|
2068
2652
|
return this.pooledTaskBranch(repository, input);
|
|
2069
2653
|
if (Boolean(input.name) === (input.keepCurrent === true) ||
|
|
2070
|
-
(input.worktreePath && !input.name)
|
|
2071
|
-
|
|
2654
|
+
(input.worktreePath && !input.name) ||
|
|
2655
|
+
(input.inPlace && input.worktreePath))
|
|
2656
|
+
throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name, and a new branch in this folder has no worktreePath.', 'task.branch');
|
|
2072
2657
|
assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
|
|
2073
2658
|
if (this.dependencies.worktreePool)
|
|
2074
2659
|
return this.pooledTaskBranch(repository, input);
|
|
@@ -2124,7 +2709,24 @@ export class BridgeService {
|
|
|
2124
2709
|
async requireBranchDecision(repository, externalTaskId) {
|
|
2125
2710
|
const git = this.dependencies.repositories.git;
|
|
2126
2711
|
const store = git.branchStore(repository.repoRoot);
|
|
2712
|
+
const holder = await this.folderHolder(repository, externalTaskId);
|
|
2713
|
+
if (holder) {
|
|
2714
|
+
const held = `This folder is held by task ${holder.externalTaskId} (branch ${holder.branch ?? 'detached HEAD'}, last activity ${holder.lastCheckpointAt ?? 'unknown'})`;
|
|
2715
|
+
throw refuse(holder.managed
|
|
2716
|
+
? `${held}, which the worktree pool allocated here. Call task.branch for ${externalTaskId} to open it in a separate worktree, then bootstrap in the returned repoRoot.`
|
|
2717
|
+
: holder.running
|
|
2718
|
+
? `${held}, which is still running in another client. Pause it there with task.pause, or call task.branch for ${externalTaskId} to open it in a separate worktree; once ${holder.externalTaskId} is paused, its start form also offers this folder. Then bootstrap in the returned repoRoot.`
|
|
2719
|
+
: holder.pendingDelivery
|
|
2720
|
+
? `${held}, which closed but never settled its delivery. Resume ${holder.externalTaskId} here and call worktree.release with deliveryOutcome delivered or cancelled to settle it, or call task.branch for ${externalTaskId} to open it in a separate worktree instead. Then bootstrap in the returned repoRoot.`
|
|
2721
|
+
: !holder.checkoutClean
|
|
2722
|
+
? `${held}, which is not known to be finished. This folder has uncommitted changes, so call task.branch for ${externalTaskId} to open it in a separate worktree, then bootstrap in the returned repoRoot.`
|
|
2723
|
+
: `${held}, which is not known to be finished. Call task.branch for ${externalTaskId}: its start form offers a new branch in this folder with ${holder.externalTaskId} moved out, or a separate worktree. Then bootstrap in the returned repoRoot.`, 'task.branch');
|
|
2724
|
+
}
|
|
2127
2725
|
let record = await store.read();
|
|
2726
|
+
if (record &&
|
|
2727
|
+
(record.decision.projectId !== repository.projectId ||
|
|
2728
|
+
record.decision.externalTaskId !== externalTaskId))
|
|
2729
|
+
record = null;
|
|
2128
2730
|
if (!record) {
|
|
2129
2731
|
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
|
|
2130
2732
|
if (pointer?.worktreeId === sha256(repository.repoRoot) &&
|
|
@@ -2140,15 +2742,102 @@ export class BridgeService {
|
|
|
2140
2742
|
}
|
|
2141
2743
|
}
|
|
2142
2744
|
if (!record)
|
|
2143
|
-
throw refuse(`
|
|
2144
|
-
if (record.decision.projectId !== repository.projectId ||
|
|
2145
|
-
record.decision.externalTaskId !== externalTaskId)
|
|
2146
|
-
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');
|
|
2745
|
+
throw refuse(`No branch was chosen for task ${externalTaskId}. Call task.branch with that externalTaskId: its one form asks where the task works, which branch it starts from and the branch name. Use its returned repoRoot.`, 'task.branch');
|
|
2147
2746
|
const branch = await git.currentBranch(repository.repoRoot);
|
|
2148
2747
|
if (branch !== record.decision.branch)
|
|
2149
2748
|
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');
|
|
2150
2749
|
return record.decision;
|
|
2151
2750
|
}
|
|
2751
|
+
async folderHolder(repository, externalTaskId) {
|
|
2752
|
+
const pool = this.dependencies.worktreePool;
|
|
2753
|
+
const store = this.dependencies.repositories.git.branchStore(repository.repoRoot);
|
|
2754
|
+
const record = await store.read();
|
|
2755
|
+
const found = pool && repository.projectId
|
|
2756
|
+
? await pool.forPath(repository.projectId, repository.repoRoot)
|
|
2757
|
+
: null;
|
|
2758
|
+
const entry = found && found.phase !== 'released' && found.externalTaskId !== externalTaskId ? found : null;
|
|
2759
|
+
const decision = record &&
|
|
2760
|
+
(record.decision.projectId !== repository.projectId ||
|
|
2761
|
+
record.decision.externalTaskId !== externalTaskId)
|
|
2762
|
+
? record.decision
|
|
2763
|
+
: null;
|
|
2764
|
+
if (!entry && !decision)
|
|
2765
|
+
return null;
|
|
2766
|
+
const slug = entry?.externalTaskId ?? decision.externalTaskId;
|
|
2767
|
+
const taskId = entry ? entry.taskId : decision.taskId;
|
|
2768
|
+
const running = entry ? await pool.ownedElsewhere(entry) : false;
|
|
2769
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, slug);
|
|
2770
|
+
let status = null;
|
|
2771
|
+
if (taskId && !entry?.managed) {
|
|
2772
|
+
try {
|
|
2773
|
+
status = objectValue((await this.dependencies.client.request(endpoints.taskStatus(taskId))).data);
|
|
2774
|
+
}
|
|
2775
|
+
catch {
|
|
2776
|
+
status = null;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
const pendingDelivery = entry?.pendingDelivery === true;
|
|
2780
|
+
if (taskId &&
|
|
2781
|
+
!entry?.managed &&
|
|
2782
|
+
!running &&
|
|
2783
|
+
!pendingDelivery &&
|
|
2784
|
+
(status?.status === 'closed' || status?.status === 'abandoned')) {
|
|
2785
|
+
if (entry) {
|
|
2786
|
+
await pool.moveOut(entry.projectId, repository.repoRoot, entry.generation);
|
|
2787
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2788
|
+
}
|
|
2789
|
+
else
|
|
2790
|
+
await store.release(taskId);
|
|
2791
|
+
if (pointer?.worktreeId === sha256(repository.repoRoot)) {
|
|
2792
|
+
if (status.status === 'abandoned')
|
|
2793
|
+
await this.dependencies.activeContexts.forget(repository.repoFingerprint, pointer.taskId);
|
|
2794
|
+
else
|
|
2795
|
+
await this.parkPointer({
|
|
2796
|
+
...pointer,
|
|
2797
|
+
closedAt: pointer.closedAt ?? new Date().toISOString(),
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
return null;
|
|
2801
|
+
}
|
|
2802
|
+
return {
|
|
2803
|
+
projectId: entry?.projectId ?? decision.projectId,
|
|
2804
|
+
externalTaskId: slug,
|
|
2805
|
+
taskId: taskId ?? null,
|
|
2806
|
+
branch: entry ? entry.branch : decision.branch,
|
|
2807
|
+
lastCheckpointAt: typeof status?.updatedAt === 'string' ? status.updatedAt : (pointer?.updatedAt ?? null),
|
|
2808
|
+
checkoutClean: repository.git.changedPaths.length === 0,
|
|
2809
|
+
oid: record && decision?.externalTaskId === slug ? record.oid : null,
|
|
2810
|
+
generation: entry && !entry.managed ? entry.generation : null,
|
|
2811
|
+
managed: entry?.managed === true,
|
|
2812
|
+
running,
|
|
2813
|
+
pendingDelivery,
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
async parkPointer(pointer) {
|
|
2817
|
+
const { worktreeId: _worktreeId, worktreeGeneration: _generation, ...parked } = pointer;
|
|
2818
|
+
await this.dependencies.activeContexts.save(parked);
|
|
2819
|
+
}
|
|
2820
|
+
async worktreeFolder(input) {
|
|
2821
|
+
return this.execute(async () => {
|
|
2822
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
2823
|
+
if (!repository.projectId)
|
|
2824
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
2825
|
+
return asJsonValue(await this.folderState(repository, input.externalTaskId));
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
async folderState(repository, externalTaskId) {
|
|
2829
|
+
const holder = await this.folderHolder(repository, externalTaskId);
|
|
2830
|
+
const entry = this.dependencies.worktreePool
|
|
2831
|
+
? await this.dependencies.worktreePool.forPath(repository.projectId, repository.repoRoot)
|
|
2832
|
+
: null;
|
|
2833
|
+
return {
|
|
2834
|
+
managed: entry?.managed === true,
|
|
2835
|
+
heldBy: holder && (holder.managed || holder.running) ? holder.externalTaskId : null,
|
|
2836
|
+
clean: repository.git.changedPaths.length === 0,
|
|
2837
|
+
deliveryPending: entry?.externalTaskId === externalTaskId && entry.pendingDelivery === true,
|
|
2838
|
+
holder: holder && !holder.managed && !holder.running ? holder : null,
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2152
2841
|
async assertTaskWorktree(repository, task, pointer) {
|
|
2153
2842
|
if (normalizeTaskMode(task.mode) === 'read_only')
|
|
2154
2843
|
return;
|
|
@@ -2177,7 +2866,10 @@ export class BridgeService {
|
|
|
2177
2866
|
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
2178
2867
|
: this.poolAllocations.get(repository.repoRoot);
|
|
2179
2868
|
if (allocation && this.dependencies.worktreePool) {
|
|
2180
|
-
|
|
2869
|
+
if (allocation.managed)
|
|
2870
|
+
await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
|
|
2871
|
+
else
|
|
2872
|
+
await this.dependencies.worktreePool.release(allocation.projectId, allocation.repoRoot, allocation.generation);
|
|
2181
2873
|
this.poolAllocations.delete(repository.repoRoot);
|
|
2182
2874
|
}
|
|
2183
2875
|
else
|
|
@@ -2202,8 +2894,8 @@ export class BridgeService {
|
|
|
2202
2894
|
organizationId: input.organizationId,
|
|
2203
2895
|
projectName: input.projectName,
|
|
2204
2896
|
projectSlug: input.projectSlug ?? slugify(input.projectName),
|
|
2205
|
-
|
|
2206
|
-
|
|
2897
|
+
...(await this.checkoutIdentity(repository)),
|
|
2898
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
2207
2899
|
framework: input.framework,
|
|
2208
2900
|
developmentArea: input.developmentArea,
|
|
2209
2901
|
figmaConfig: input.figmaConfig,
|
|
@@ -2214,7 +2906,7 @@ export class BridgeService {
|
|
|
2214
2906
|
}
|
|
2215
2907
|
const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
|
|
2216
2908
|
assertSafeToPersist(persistentBody);
|
|
2217
|
-
const response = await this.
|
|
2909
|
+
const response = await this.requestWithNewerFields(endpoints.projectSetup, ['remoteIdentity'], {
|
|
2218
2910
|
method: 'POST',
|
|
2219
2911
|
body: persistentBody,
|
|
2220
2912
|
});
|
|
@@ -2257,7 +2949,7 @@ export class BridgeService {
|
|
|
2257
2949
|
else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
|
|
2258
2950
|
throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
|
|
2259
2951
|
}
|
|
2260
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
|
|
2952
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint, livesIn(project));
|
|
2261
2953
|
return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
|
|
2262
2954
|
});
|
|
2263
2955
|
}
|
|
@@ -2564,12 +3256,12 @@ export class BridgeService {
|
|
|
2564
3256
|
repository: publicRepository(repository),
|
|
2565
3257
|
});
|
|
2566
3258
|
}
|
|
2567
|
-
const response = await this.
|
|
3259
|
+
const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
|
|
2568
3260
|
method: 'POST',
|
|
2569
|
-
body: {
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
},
|
|
3261
|
+
body: cleanJson({
|
|
3262
|
+
...(await this.checkoutIdentity(repository)),
|
|
3263
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3264
|
+
}),
|
|
2573
3265
|
});
|
|
2574
3266
|
const project = objectValue(response.data);
|
|
2575
3267
|
if (!project ||
|
|
@@ -2577,7 +3269,7 @@ export class BridgeService {
|
|
|
2577
3269
|
typeof project.repoFingerprint !== 'string') {
|
|
2578
3270
|
throw refuse('Project bind response does not match the selected project', 'project.resolve');
|
|
2579
3271
|
}
|
|
2580
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
|
|
3272
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
2581
3273
|
return asJsonValue({
|
|
2582
3274
|
resolved: true,
|
|
2583
3275
|
bound: true,
|
|
@@ -2590,31 +3282,110 @@ export class BridgeService {
|
|
|
2590
3282
|
}
|
|
2591
3283
|
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
2592
3284
|
method: 'POST',
|
|
2593
|
-
body:
|
|
2594
|
-
repoFingerprint: repository.repoFingerprint,
|
|
2595
|
-
legacyFingerprints: repository.legacyFingerprints,
|
|
2596
|
-
},
|
|
3285
|
+
body: await this.checkoutIdentity(repository),
|
|
2597
3286
|
});
|
|
2598
3287
|
const project = objectValue(response.data);
|
|
2599
3288
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
2600
3289
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
3290
|
+
const moved = project?.checkout === 'retired';
|
|
2601
3291
|
return asJsonValue({
|
|
2602
|
-
resolved: project !== null && !restoreRequired,
|
|
3292
|
+
resolved: project !== null && !restoreRequired && !moved,
|
|
2603
3293
|
project,
|
|
2604
3294
|
markerPath: null,
|
|
2605
3295
|
repository: publicRepository(repository),
|
|
2606
3296
|
selectionRequired: project === null,
|
|
2607
3297
|
restoreRequired,
|
|
2608
3298
|
...(restoreRequired ? restoreGuidance : {}),
|
|
3299
|
+
...movedProjectGuidance(project),
|
|
3300
|
+
});
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
async projectRepositoryMovePreflight(input) {
|
|
3304
|
+
return await this.execute(async () => {
|
|
3305
|
+
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
3306
|
+
if (authentication)
|
|
3307
|
+
return asJsonValue({ authentication });
|
|
3308
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3309
|
+
const history = await this.dependencies.client.request(endpoints.projectRepository(input.projectId));
|
|
3310
|
+
const identity = await this.checkoutIdentity(repository);
|
|
3311
|
+
const current = objectValue(objectValue(history.data)?.current);
|
|
3312
|
+
return asJsonValue({
|
|
3313
|
+
history: history.data,
|
|
3314
|
+
checkout: {
|
|
3315
|
+
...identity,
|
|
3316
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3317
|
+
},
|
|
3318
|
+
alreadyHere: typeof current?.repoFingerprint === 'string' &&
|
|
3319
|
+
[identity.repoFingerprint, ...identity.legacyFingerprints].includes(current.repoFingerprint),
|
|
2609
3320
|
});
|
|
2610
3321
|
});
|
|
2611
3322
|
}
|
|
3323
|
+
async projectMoveRepository(input) {
|
|
3324
|
+
return await this.execute(async () => {
|
|
3325
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3326
|
+
const response = await this.dependencies.client.request(endpoints.projectRepository(input.projectId), {
|
|
3327
|
+
method: 'POST',
|
|
3328
|
+
body: cleanJson({
|
|
3329
|
+
...(await this.checkoutIdentity(repository)),
|
|
3330
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3331
|
+
expectedVersion: input.expectedVersion,
|
|
3332
|
+
}),
|
|
3333
|
+
});
|
|
3334
|
+
const project = objectValue(response.data);
|
|
3335
|
+
if (!project ||
|
|
3336
|
+
project.id !== input.projectId ||
|
|
3337
|
+
typeof project.repoFingerprint !== 'string' ||
|
|
3338
|
+
!livesIn(project)) {
|
|
3339
|
+
throw refuse('Project move response does not match the selected project', 'project.move_repository');
|
|
3340
|
+
}
|
|
3341
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
3342
|
+
return asJsonValue({
|
|
3343
|
+
moved: true,
|
|
3344
|
+
bound: true,
|
|
3345
|
+
project,
|
|
3346
|
+
bindingPath,
|
|
3347
|
+
repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
|
|
3348
|
+
nextAction: 'The project now lives in this repository and this checkout is bound to it. Every task, memory record and work item stayed with the project. Continue with session.bootstrap or session.resume.',
|
|
3349
|
+
});
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
async checkoutIdentity(repository) {
|
|
3353
|
+
const [current, ...older] = await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot);
|
|
3354
|
+
return {
|
|
3355
|
+
repoFingerprint: current,
|
|
3356
|
+
legacyFingerprints: [
|
|
3357
|
+
...new Set([...older, repository.repoFingerprint, ...repository.legacyFingerprints]),
|
|
3358
|
+
]
|
|
3359
|
+
.filter((fingerprint) => fingerprint !== current)
|
|
3360
|
+
.slice(0, 4),
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
async requestWithNewerFields(path, newer, options) {
|
|
3364
|
+
try {
|
|
3365
|
+
return await this.dependencies.client.request(path, options);
|
|
3366
|
+
}
|
|
3367
|
+
catch (error) {
|
|
3368
|
+
const body = objectValue(options.body);
|
|
3369
|
+
if (!body ||
|
|
3370
|
+
!(error instanceof ApiResponseError) ||
|
|
3371
|
+
error.httpStatus !== 400 ||
|
|
3372
|
+
!newer.some((name) => error.message.includes(`property ${name} should not exist`)))
|
|
3373
|
+
throw error;
|
|
3374
|
+
return await this.dependencies.client.request(path, {
|
|
3375
|
+
...options,
|
|
3376
|
+
body: Object.fromEntries(Object.entries(body).filter(([name]) => !newer.includes(name))),
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
2612
3380
|
async sessionEntry(input = {}) {
|
|
2613
3381
|
return await this.execute(async () => {
|
|
3382
|
+
const timer = startPhaseTimer('session.entry');
|
|
2614
3383
|
const authentication = await this.dependencies.browserAuth.status();
|
|
3384
|
+
timer.mark('auth_status');
|
|
2615
3385
|
let repository;
|
|
2616
3386
|
try {
|
|
2617
3387
|
repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
3388
|
+
timer.mark('resolve_repository');
|
|
2618
3389
|
}
|
|
2619
3390
|
catch (error) {
|
|
2620
3391
|
const missingTarget = error.code === 'ENOENT';
|
|
@@ -2636,32 +3407,47 @@ export class BridgeService {
|
|
|
2636
3407
|
});
|
|
2637
3408
|
}
|
|
2638
3409
|
const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
|
|
3410
|
+
timer.mark('load_decision');
|
|
2639
3411
|
const authenticated = authentication.authenticated === true;
|
|
2640
3412
|
const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
|
|
2641
|
-
const
|
|
3413
|
+
const projectId = repository.projectId;
|
|
3414
|
+
const livePointers = authenticated && state === 'bound'
|
|
3415
|
+
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
3416
|
+
: Promise.resolve([]);
|
|
3417
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved,] = await Promise.all([
|
|
3418
|
+
this.clientUpdate(authenticated),
|
|
3419
|
+
livePointers,
|
|
3420
|
+
authenticated && state === 'bound' && projectId
|
|
3421
|
+
? this.actionableWorkItems(projectId)
|
|
3422
|
+
: Promise.resolve([]),
|
|
3423
|
+
authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
3424
|
+
? this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
|
|
3425
|
+
: Promise.resolve(null),
|
|
3426
|
+
authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
3427
|
+
? this.worktreeOperation('list', { repoRoot: repository.repoRoot })
|
|
3428
|
+
: Promise.resolve(null),
|
|
3429
|
+
state !== 'disabled'
|
|
3430
|
+
? Promise.all([
|
|
3431
|
+
livePointers,
|
|
3432
|
+
this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
|
|
3433
|
+
]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
|
|
3434
|
+
: Promise.resolve({ mine: [], other: [], deferred: [] }),
|
|
3435
|
+
authenticated && state === 'bound' && projectId
|
|
3436
|
+
? this.movedAway(repository, projectId)
|
|
3437
|
+
: Promise.resolve({}),
|
|
3438
|
+
]);
|
|
3439
|
+
timer.mark('independent_lookups');
|
|
3440
|
+
const liveTasks = liveTasksRaw.map(describePointer);
|
|
2642
3441
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
2643
3442
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
2644
|
-
const projectId = repository.projectId;
|
|
2645
|
-
const liveTasks = authenticated && state === 'bound'
|
|
2646
|
-
? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
|
|
2647
|
-
: [];
|
|
2648
|
-
const workItems = authenticated && state === 'bound' && projectId
|
|
2649
|
-
? await this.actionableWorkItems(projectId)
|
|
2650
|
-
: [];
|
|
2651
|
-
const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
2652
|
-
? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
|
|
2653
|
-
: null;
|
|
2654
3443
|
const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
|
|
2655
3444
|
const unansweredBranches = preferences?.canManage === true
|
|
2656
3445
|
? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
|
|
2657
3446
|
.filter(([, value]) => objectValue(value)?.answered === false)
|
|
2658
3447
|
.map(([role]) => role)
|
|
2659
3448
|
: [];
|
|
2660
|
-
const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
2661
|
-
? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
|
|
2662
|
-
: null;
|
|
2663
3449
|
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
2664
|
-
|
|
3450
|
+
timer.finish();
|
|
2665
3451
|
return asJsonValue({
|
|
2666
3452
|
authenticated,
|
|
2667
3453
|
repository: publicRepository(repository),
|
|
@@ -2676,25 +3462,44 @@ export class BridgeService {
|
|
|
2676
3462
|
gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
|
|
2677
3463
|
unansweredBranchPreferences: unansweredBranches,
|
|
2678
3464
|
branchPreferenceAction: unansweredBranches.length
|
|
2679
|
-
? 'Ask
|
|
3465
|
+
? 'Ask these unanswered shared branch preferences with project.set_git_preferences, which puts them all in one form. Other members choose only their own task base in task.branch.'
|
|
2680
3466
|
: 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
|
|
2681
3467
|
}
|
|
2682
3468
|
: {}),
|
|
2683
|
-
...(state !== 'disabled' ?
|
|
3469
|
+
...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
|
|
3470
|
+
...(pendingQuestionnaires.deferred.length > 0
|
|
3471
|
+
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
3472
|
+
: {}),
|
|
2684
3473
|
client,
|
|
3474
|
+
...moved,
|
|
2685
3475
|
...(incomplete && state === 'bound'
|
|
2686
3476
|
? {
|
|
2687
3477
|
shippedKnowledgeWarning: 'The backend is not serving all of the knowledge this release ships. Tell the user before relying on the rules: some are missing from the running server, so a review against them is incomplete. The counts and the reason are in client.shippedKnowledge.',
|
|
2688
3478
|
}
|
|
2689
3479
|
: {}),
|
|
2690
|
-
nextAction:
|
|
2691
|
-
?
|
|
2692
|
-
:
|
|
2693
|
-
? '
|
|
2694
|
-
:
|
|
3480
|
+
nextAction: typeof moved.nextAction === 'string'
|
|
3481
|
+
? moved.nextAction
|
|
3482
|
+
: pendingQuestionnaires.mine.length > 0
|
|
3483
|
+
? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
|
|
3484
|
+
: taskSelectionRequired
|
|
3485
|
+
? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or start a separate run for the chosen work item: task.branch first for write work, then session.bootstrap with its workItemId in the returned repoRoot. Another live task never blocks opening this one.'
|
|
3486
|
+
: entryNextAction(authenticated, state),
|
|
2695
3487
|
});
|
|
2696
3488
|
});
|
|
2697
3489
|
}
|
|
3490
|
+
async movedAway(repository, projectId) {
|
|
3491
|
+
try {
|
|
3492
|
+
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
3493
|
+
method: 'POST',
|
|
3494
|
+
body: await this.checkoutIdentity(repository),
|
|
3495
|
+
});
|
|
3496
|
+
const project = objectValue(response.data);
|
|
3497
|
+
return project?.id === projectId ? movedProjectGuidance(project, true) : {};
|
|
3498
|
+
}
|
|
3499
|
+
catch {
|
|
3500
|
+
return {};
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
2698
3503
|
async actionableWorkItems(projectId) {
|
|
2699
3504
|
try {
|
|
2700
3505
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
|
|
@@ -2749,7 +3554,7 @@ export class BridgeService {
|
|
|
2749
3554
|
}
|
|
2750
3555
|
async sessionSetDecision(input) {
|
|
2751
3556
|
return await this.execute(async () => {
|
|
2752
|
-
const repository = await this.dependencies.repositories.
|
|
3557
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
2753
3558
|
if (input.decision === 'none') {
|
|
2754
3559
|
await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
|
|
2755
3560
|
return asJsonValue({
|
|
@@ -2892,6 +3697,10 @@ export class BridgeService {
|
|
|
2892
3697
|
}
|
|
2893
3698
|
const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
|
|
2894
3699
|
const pointer = await this.resolveCheckpointPointer(input, repoRoot);
|
|
3700
|
+
if (pointer.closedAt && correction)
|
|
3701
|
+
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections. Record the user's correction as a proposal for the rule it concerns with memory.propose_revision, or in the task that next changes this code.`, 'memory.propose_revision');
|
|
3702
|
+
if (pointer.closedAt)
|
|
3703
|
+
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. Further work belongs to a new task.`, 'session.bootstrap');
|
|
2895
3704
|
const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
|
|
2896
3705
|
if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
|
|
2897
3706
|
throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
|
|
@@ -3013,7 +3822,7 @@ export class BridgeService {
|
|
|
3013
3822
|
throw refuse('The receipt does not belong to this task project.', 'session.resume');
|
|
3014
3823
|
if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
|
|
3015
3824
|
throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
|
|
3016
|
-
await this.checkoutSource(repository, pointer.source);
|
|
3825
|
+
await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
|
|
3017
3826
|
await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
|
|
3018
3827
|
const entries = await this.dependencies.outbox.listForTask(taskId);
|
|
3019
3828
|
if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
|
|
@@ -3127,19 +3936,16 @@ export class BridgeService {
|
|
|
3127
3936
|
});
|
|
3128
3937
|
const backend = objectValue(response.data);
|
|
3129
3938
|
const task = objectValue(backend?.task);
|
|
3130
|
-
if (!task ||
|
|
3131
|
-
task.id !== body.taskId ||
|
|
3132
|
-
task.projectId !== entry.projectId ||
|
|
3133
|
-
backend?.requiresContextRefresh === true)
|
|
3939
|
+
if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
|
|
3134
3940
|
throw refuse('Refresh the matching task context before delivering this review.', 'session.resume');
|
|
3941
|
+
if (backend?.requiresContextRefresh === true)
|
|
3942
|
+
throw refuse('Approved memory changed what this task read, so its context must be refreshed before this review is delivered.', 'context.refresh');
|
|
3135
3943
|
recovered = Boolean(task &&
|
|
3136
3944
|
task.id === body.taskId &&
|
|
3137
3945
|
task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
|
|
3138
3946
|
task.selfReviewDiffHash === body.diffHash &&
|
|
3139
|
-
stableStringify(asJsonValue(task.
|
|
3140
|
-
stableStringify(asJsonValue(body.
|
|
3141
|
-
stableStringify(asJsonValue(task.selfReviewFindings)) ===
|
|
3142
|
-
stableStringify(asJsonValue(body.findings)));
|
|
3947
|
+
stableStringify(asJsonValue(task.selfReviewFiles)) ===
|
|
3948
|
+
stableStringify(asJsonValue(body.files)));
|
|
3143
3949
|
if (recovered)
|
|
3144
3950
|
await this.captureTaskSnapshot(response.data);
|
|
3145
3951
|
}
|
|
@@ -3182,13 +3988,34 @@ export class BridgeService {
|
|
|
3182
3988
|
}
|
|
3183
3989
|
return pending.length;
|
|
3184
3990
|
}
|
|
3185
|
-
async
|
|
3991
|
+
async taskRepository(repository, pointer) {
|
|
3992
|
+
const base = pointer?.source?.sourceCommit;
|
|
3993
|
+
const git = this.dependencies.repositories.git;
|
|
3994
|
+
if (!pointer ||
|
|
3995
|
+
!base ||
|
|
3996
|
+
!repository.git.head ||
|
|
3997
|
+
repository.git.head === base ||
|
|
3998
|
+
normalizeTaskMode(pointer.mode) === 'read_only' ||
|
|
3999
|
+
!pointer.changeBaseline ||
|
|
4000
|
+
typeof pointer.branch !== 'string' ||
|
|
4001
|
+
(await git.currentBranch(repository.repoRoot)) !== pointer.branch ||
|
|
4002
|
+
!(await git.isAncestor(repository.repoRoot, base)) ||
|
|
4003
|
+
!pathsContainAll(pointer.changeBaseline.leasePaths, await git.committedPaths(repository.repoRoot, base)))
|
|
4004
|
+
return repository;
|
|
4005
|
+
return { ...repository, git: await git.manifestAgainst(repository.repoRoot, base) };
|
|
4006
|
+
}
|
|
4007
|
+
async checkoutSource(repository, expected, task) {
|
|
3186
4008
|
const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
|
|
3187
4009
|
if (expected &&
|
|
3188
4010
|
(!current ||
|
|
3189
4011
|
current.sourceCommit !== expected.sourceCommit ||
|
|
3190
4012
|
current.treeHash !== expected.treeHash)) {
|
|
3191
|
-
|
|
4013
|
+
if (current &&
|
|
4014
|
+
repository.git.baseCommit === expected.sourceCommit &&
|
|
4015
|
+
repository.git.head === current.sourceCommit)
|
|
4016
|
+
return { ...expected };
|
|
4017
|
+
const branch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
4018
|
+
throw refuse(`The checkout no longer matches this task source. ${task?.taskSlug ? 'Task ' + task.taskSlug : 'This task'} was opened${task?.branch ? ' on ' + task.branch : ''} at commit ${expected.sourceCommit.slice(0, 12)}; this checkout is on ${branch ?? 'detached HEAD'} at ${current?.sourceCommit.slice(0, 12) ?? 'no commit'}, which is not the task's own work committed on top of its source. Ask the user through the native questionnaire which way to go: switch this checkout back to ${task?.branch ?? 'that commit'} and continue the task, or end it with task.abandon.`, 'task.abandon');
|
|
3192
4019
|
}
|
|
3193
4020
|
if (current && repository.git.head !== current.sourceCommit) {
|
|
3194
4021
|
throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
|
|
@@ -3214,14 +4041,17 @@ export class BridgeService {
|
|
|
3214
4041
|
body: {
|
|
3215
4042
|
sessionId: pointer.sessionId,
|
|
3216
4043
|
repoFingerprint: repository.repoFingerprint,
|
|
3217
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
4044
|
+
source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
|
|
3218
4045
|
afterSequence: 0,
|
|
3219
4046
|
},
|
|
3220
4047
|
});
|
|
3221
4048
|
const backend = objectValue(response.data);
|
|
3222
4049
|
const task = objectValue(backend?.task);
|
|
3223
|
-
if (!task || task.id !== taskId
|
|
3224
|
-
throw refuse('Backend task snapshot
|
|
4050
|
+
if (!task || task.id !== taskId) {
|
|
4051
|
+
throw refuse('Backend task snapshot does not match the active task', 'session.resume');
|
|
4052
|
+
}
|
|
4053
|
+
if (backend?.requiresContextRefresh === true) {
|
|
4054
|
+
throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
3225
4055
|
}
|
|
3226
4056
|
const refreshed = {
|
|
3227
4057
|
...pointer,
|
|
@@ -3243,8 +4073,11 @@ export class BridgeService {
|
|
|
3243
4073
|
});
|
|
3244
4074
|
const backend = objectValue(response.data);
|
|
3245
4075
|
const task = objectValue(backend?.task);
|
|
3246
|
-
if (!task || task.id !== taskId
|
|
3247
|
-
throw refuse('Backend task snapshot
|
|
4076
|
+
if (!task || task.id !== taskId) {
|
|
4077
|
+
throw refuse('Backend task snapshot does not match the pending delivery', 'session.resume');
|
|
4078
|
+
}
|
|
4079
|
+
if (backend?.requiresContextRefresh === true) {
|
|
4080
|
+
throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
3248
4081
|
}
|
|
3249
4082
|
const refreshed = {
|
|
3250
4083
|
...pointer,
|
|
@@ -3259,6 +4092,13 @@ export class BridgeService {
|
|
|
3259
4092
|
const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
|
|
3260
4093
|
const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
|
|
3261
4094
|
(pointer?.closeIntent && allowedIntent !== 'close');
|
|
4095
|
+
if (pointer &&
|
|
4096
|
+
pointer.taskId === taskId &&
|
|
4097
|
+
!lifecycleIntentBlocked &&
|
|
4098
|
+
blockingConflicts.length > 0 &&
|
|
4099
|
+
blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
|
|
4100
|
+
throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
|
|
4101
|
+
}
|
|
3262
4102
|
if (!pointer ||
|
|
3263
4103
|
pointer.taskId !== taskId ||
|
|
3264
4104
|
(blockingConflicts?.length ?? 0) > 0 ||
|
|
@@ -3275,7 +4115,7 @@ export class BridgeService {
|
|
|
3275
4115
|
}
|
|
3276
4116
|
}
|
|
3277
4117
|
async retryVerificationRecovery(repository, pointer, backend) {
|
|
3278
|
-
await this.checkoutSource(repository, pointer.source);
|
|
4118
|
+
await this.checkoutSource(repository, pointer.source, pointer);
|
|
3279
4119
|
const intent = pointer.verificationIntent;
|
|
3280
4120
|
const recovery = objectValue(backend?.verificationRecovery);
|
|
3281
4121
|
if (!intent) {
|
|
@@ -3402,7 +4242,9 @@ export class BridgeService {
|
|
|
3402
4242
|
return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
|
|
3403
4243
|
}
|
|
3404
4244
|
async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
|
|
3405
|
-
await this.checkoutSource(repository, sourceFromContext(body.source)
|
|
4245
|
+
await this.checkoutSource(repository, sourceFromContext(body.source), {
|
|
4246
|
+
branch: typeof body.branch === 'string' ? body.branch : null,
|
|
4247
|
+
});
|
|
3406
4248
|
const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
|
|
3407
4249
|
const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
3408
4250
|
if (typeof body.branch === 'string' && body.branch !== currentBranch)
|
|
@@ -3443,6 +4285,12 @@ export class BridgeService {
|
|
|
3443
4285
|
taskChanges,
|
|
3444
4286
|
});
|
|
3445
4287
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
4288
|
+
const closedPointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, String(body.taskId));
|
|
4289
|
+
if (closedPointer)
|
|
4290
|
+
await this.dependencies.activeContexts.save({
|
|
4291
|
+
...closedPointer,
|
|
4292
|
+
closedAt: new Date().toISOString(),
|
|
4293
|
+
});
|
|
3446
4294
|
const allocation = repository.projectId
|
|
3447
4295
|
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
3448
4296
|
: this.poolAllocations.get(repository.repoRoot);
|
|
@@ -3456,7 +4304,7 @@ export class BridgeService {
|
|
|
3456
4304
|
...closedTask,
|
|
3457
4305
|
repository: publicRepository(repository),
|
|
3458
4306
|
recoveredAfterResponseLoss,
|
|
3459
|
-
delivery: deliveryQuestion(touchedContract),
|
|
4307
|
+
delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
|
|
3460
4308
|
});
|
|
3461
4309
|
}
|
|
3462
4310
|
async seedResumeSnapshot(pointer, backendPatch) {
|
|
@@ -3506,7 +4354,14 @@ export class BridgeService {
|
|
|
3506
4354
|
return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
|
|
3507
4355
|
})()
|
|
3508
4356
|
: await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
|
|
3509
|
-
|
|
4357
|
+
const blockingConflicts = pointer.resumeConflicts.filter((conflict) => !isOfflineDevelopmentWarning(conflict));
|
|
4358
|
+
if (!pointer.verificationIntent &&
|
|
4359
|
+
!pointer.closeIntent &&
|
|
4360
|
+
blockingConflicts.length > 0 &&
|
|
4361
|
+
blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
|
|
4362
|
+
throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
|
|
4363
|
+
}
|
|
4364
|
+
if (blockingConflicts.length > 0) {
|
|
3510
4365
|
throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
|
|
3511
4366
|
}
|
|
3512
4367
|
if (pointer.verificationIntent || pointer.closeIntent) {
|
|
@@ -3587,6 +4442,7 @@ export class BridgeService {
|
|
|
3587
4442
|
code: error.code ?? error.httpStatus,
|
|
3588
4443
|
retryable: error.retryable,
|
|
3589
4444
|
...(error.recovery ? { recovery: error.recovery } : {}),
|
|
4445
|
+
...(error.envelope?.data ? { details: error.envelope.data } : {}),
|
|
3590
4446
|
},
|
|
3591
4447
|
};
|
|
3592
4448
|
}
|
|
@@ -3597,6 +4453,9 @@ export class BridgeService {
|
|
|
3597
4453
|
message: error instanceof Error ? error.message : 'Bridge operation failed',
|
|
3598
4454
|
retryable: false,
|
|
3599
4455
|
...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
|
|
4456
|
+
...(error instanceof BridgeRecoveryError && error.details !== undefined
|
|
4457
|
+
? { details: error.details }
|
|
4458
|
+
: {}),
|
|
3600
4459
|
},
|
|
3601
4460
|
};
|
|
3602
4461
|
}
|
|
@@ -3614,6 +4473,59 @@ function normalizeChangedPaths(paths) {
|
|
|
3614
4473
|
}
|
|
3615
4474
|
return [...new Set(normalized)].sort();
|
|
3616
4475
|
}
|
|
4476
|
+
function reviewedFiles(input) {
|
|
4477
|
+
const repoRoot = input.repoRoot ?? process.cwd();
|
|
4478
|
+
const files = input.files.map(({ path, rules }) => ({
|
|
4479
|
+
path: reviewedFilePath(path),
|
|
4480
|
+
rules: rules.map((rule) => normalizePersistentInput(rule, repoRoot)),
|
|
4481
|
+
}));
|
|
4482
|
+
const repeated = files
|
|
4483
|
+
.map(({ path }) => path)
|
|
4484
|
+
.filter((path, index, paths) => paths.indexOf(path) !== index);
|
|
4485
|
+
if (repeated.length > 0)
|
|
4486
|
+
throw refuse(`The self review lists these files more than once: ${[...new Set(repeated)].join(', ')}. Record one entry per changed file with every rule that governs it.`, 'task.self_review');
|
|
4487
|
+
return files;
|
|
4488
|
+
}
|
|
4489
|
+
function reviewedFilePath(path) {
|
|
4490
|
+
const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
4491
|
+
if (!normalized ||
|
|
4492
|
+
normalized.startsWith('/') ||
|
|
4493
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
4494
|
+
normalized.split('/').includes('..'))
|
|
4495
|
+
throw refuse(`Name each reviewed file by its repository-relative path, as Git reports it: ${path}`, 'task.self_review');
|
|
4496
|
+
return normalized;
|
|
4497
|
+
}
|
|
4498
|
+
function isDeviation(rule) {
|
|
4499
|
+
return rule.outcome === 'user_accepted_deviation';
|
|
4500
|
+
}
|
|
4501
|
+
export function ruleDeviationQuestionnaireId(taskId, path, rule) {
|
|
4502
|
+
return ('rule-deviation-' +
|
|
4503
|
+
sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
|
|
4504
|
+
}
|
|
4505
|
+
function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
4506
|
+
const copy = ruleDeviationCopy[language];
|
|
4507
|
+
return {
|
|
4508
|
+
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4509
|
+
message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
|
|
4510
|
+
language,
|
|
4511
|
+
options: [
|
|
4512
|
+
{ id: 'approve', label: copy.approve },
|
|
4513
|
+
{ id: 'change', label: copy.change },
|
|
4514
|
+
],
|
|
4515
|
+
};
|
|
4516
|
+
}
|
|
4517
|
+
const ruleDeviationCopy = {
|
|
4518
|
+
en: {
|
|
4519
|
+
message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
|
|
4520
|
+
approve: 'Keep the code as written and record the deviation',
|
|
4521
|
+
change: 'Change the code to follow the rule',
|
|
4522
|
+
},
|
|
4523
|
+
tr: {
|
|
4524
|
+
message: (path, resourceKey, issue) => `${path}, ${resourceKey} kuralına uymuyor: ${issue}`,
|
|
4525
|
+
approve: 'Kodu olduğu gibi bırak ve bu sapmayı kaydet',
|
|
4526
|
+
change: 'Kodu kurala uyacak şekilde değiştir',
|
|
4527
|
+
},
|
|
4528
|
+
};
|
|
3617
4529
|
function pathsContainAll(allowedPaths, actualPaths) {
|
|
3618
4530
|
const allowed = new Set(normalizeChangedPaths(allowedPaths));
|
|
3619
4531
|
return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
|
|
@@ -3694,6 +4606,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
|
|
|
3694
4606
|
});
|
|
3695
4607
|
}));
|
|
3696
4608
|
}
|
|
4609
|
+
function pendingQuestionnairesFields(value) {
|
|
4610
|
+
return {
|
|
4611
|
+
pendingQuestionnaires: value.mine,
|
|
4612
|
+
...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
|
|
4613
|
+
};
|
|
4614
|
+
}
|
|
3697
4615
|
function describePointer(pointer) {
|
|
3698
4616
|
return asJsonValue({
|
|
3699
4617
|
taskSlug: pointer.taskSlug,
|
|
@@ -3740,26 +4658,42 @@ function entryNextAction(authenticated, decision) {
|
|
|
3740
4658
|
return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
|
|
3741
4659
|
}
|
|
3742
4660
|
if (decision === 'bound') {
|
|
3743
|
-
return 'Continue into the lifecycle
|
|
4661
|
+
return 'Continue into the lifecycle. For a task already running here, call session.resume. For new write work, call task.branch first: one form asks where the branch starts, its name and which folder it uses. Then call session.bootstrap in the repoRoot it returns. Read-only work goes straight to session.bootstrap.';
|
|
3744
4662
|
}
|
|
3745
4663
|
return 'Ask which organization and then which project, whatever the user asked for, listing what they already have with the option to create a new one last. Switching Engineering Memory off in this repository is the other answer, and it is remembered.';
|
|
3746
4664
|
}
|
|
3747
|
-
function deliveryQuestion(touchedContract
|
|
4665
|
+
function deliveryQuestion(touchedContract, destination, publishApplicable) {
|
|
4666
|
+
const { branch, remote, pushUrl, remoteDefaultBranch, headCommit } = destination;
|
|
4667
|
+
const onto = branch ? `branch ${branch}` : 'a detached HEAD, which is on no branch';
|
|
4668
|
+
const target = branch && remote ? `${remote} (${pushUrl ?? 'no push URL'}) branch ${branch}` : null;
|
|
4669
|
+
const head = headCommit ? `, on top of ${headCommit.slice(0, 12)}` : '';
|
|
4670
|
+
const pushed = target ? `, and a push goes to ${target}` : '';
|
|
4671
|
+
const to = target ? ` to ${target}` : '';
|
|
4672
|
+
const blocked = target
|
|
4673
|
+
? ''
|
|
4674
|
+
: !branch
|
|
4675
|
+
? ' (not possible until the work is on a branch)'
|
|
4676
|
+
: ` (not possible: this clone has no remote that a push of ${branch} would go to)`;
|
|
4677
|
+
const base = 'base branch the pull request targets' +
|
|
4678
|
+
(remote && remoteDefaultBranch
|
|
4679
|
+
? `; the default branch of ${remote} is ${remoteDefaultBranch}`
|
|
4680
|
+
: '');
|
|
3748
4681
|
return asJsonValue({
|
|
3749
4682
|
required: true,
|
|
3750
|
-
question:
|
|
4683
|
+
question: `The task is closed and nothing has been committed. The commit goes on ${onto}${head}${pushed}. If the user already said in their own message which of these they want, that is the answer; do not ask it again. Otherwise ask, and do only what they choose.`,
|
|
4684
|
+
destination,
|
|
3751
4685
|
options: [
|
|
3752
|
-
{ id: 'commit', label:
|
|
3753
|
-
{ id: 'commit_push', label:
|
|
4686
|
+
{ id: 'commit', label: `Commit on ${onto}` },
|
|
4687
|
+
{ id: 'commit_push', label: `Commit and push${to}${blocked}` },
|
|
3754
4688
|
{
|
|
3755
4689
|
id: 'commit_push_draft_pr',
|
|
3756
|
-
label:
|
|
3757
|
-
input:
|
|
4690
|
+
label: `Commit, push${to} and open a draft pull request${blocked}`,
|
|
4691
|
+
input: base,
|
|
3758
4692
|
},
|
|
3759
4693
|
{
|
|
3760
4694
|
id: 'commit_push_pr',
|
|
3761
|
-
label:
|
|
3762
|
-
input:
|
|
4695
|
+
label: `Commit, push${to} and open a pull request${blocked}`,
|
|
4696
|
+
input: base,
|
|
3763
4697
|
},
|
|
3764
4698
|
],
|
|
3765
4699
|
...(touchedContract
|
|
@@ -3768,6 +4702,9 @@ function deliveryQuestion(touchedContract = false) {
|
|
|
3768
4702
|
}
|
|
3769
4703
|
: {}),
|
|
3770
4704
|
afterPullRequest: 'Do not end the turn once a pull request exists. Check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything.',
|
|
4705
|
+
afterCommit: publishApplicable
|
|
4706
|
+
? 'Call memory.publish_task with this closed task id and repoRoot after the authorized commit is created.'
|
|
4707
|
+
: 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.',
|
|
3771
4708
|
});
|
|
3772
4709
|
}
|
|
3773
4710
|
function cleanJson(value) {
|
|
@@ -3790,6 +4727,73 @@ function objectValue(value) {
|
|
|
3790
4727
|
function objectOrEmpty(value) {
|
|
3791
4728
|
return objectValue(value ?? undefined) ?? {};
|
|
3792
4729
|
}
|
|
4730
|
+
function journalSummary(journal) {
|
|
4731
|
+
return {
|
|
4732
|
+
directory: journal.directory,
|
|
4733
|
+
appliedEventCount: journal.projection?.appliedEventIds.length ?? 0,
|
|
4734
|
+
pendingDeliveryCount: journal.pendingDeliveryCount,
|
|
4735
|
+
};
|
|
4736
|
+
}
|
|
4737
|
+
function outboxSummary(outbox) {
|
|
4738
|
+
const value = objectOrEmpty(outbox);
|
|
4739
|
+
return {
|
|
4740
|
+
synchronizedCount: Array.isArray(value.synchronized) ? value.synchronized.length : 0,
|
|
4741
|
+
blocked: Array.isArray(value.blocked) ? value.blocked : [],
|
|
4742
|
+
pending: typeof value.pending === 'number' ? value.pending : 0,
|
|
4743
|
+
};
|
|
4744
|
+
}
|
|
4745
|
+
function documentViews(documents) {
|
|
4746
|
+
return (Array.isArray(documents) ? documents : []).flatMap((document) => {
|
|
4747
|
+
const value = objectValue(document);
|
|
4748
|
+
return value && typeof value.content === 'string' && value.content.trim()
|
|
4749
|
+
? [{ documentType: value.documentType ?? null, content: value.content }]
|
|
4750
|
+
: [];
|
|
4751
|
+
});
|
|
4752
|
+
}
|
|
4753
|
+
function checkpointView(checkpoint) {
|
|
4754
|
+
if (!checkpoint)
|
|
4755
|
+
return null;
|
|
4756
|
+
const event = objectValue(checkpoint.event);
|
|
4757
|
+
return {
|
|
4758
|
+
event: event
|
|
4759
|
+
? {
|
|
4760
|
+
id: event.id ?? null,
|
|
4761
|
+
sequence: event.sequence ?? null,
|
|
4762
|
+
checkpointType: event.checkpointType ?? null,
|
|
4763
|
+
createdAt: event.createdAt ?? null,
|
|
4764
|
+
}
|
|
4765
|
+
: null,
|
|
4766
|
+
documents: documentViews(checkpoint.documents),
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
function resumeBackendView(backend, includeDocuments) {
|
|
4770
|
+
const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
|
|
4771
|
+
const task = objectValue(backend.task);
|
|
4772
|
+
const selfReviewFiles = Array.isArray(task?.selfReviewFiles) ? task.selfReviewFiles : [];
|
|
4773
|
+
return {
|
|
4774
|
+
...rest,
|
|
4775
|
+
...(task
|
|
4776
|
+
? {
|
|
4777
|
+
task: {
|
|
4778
|
+
...Object.fromEntries(Object.entries(task).filter(([key]) => key !== 'selfReviewFiles')),
|
|
4779
|
+
selfReviewFileCount: selfReviewFiles.length,
|
|
4780
|
+
},
|
|
4781
|
+
}
|
|
4782
|
+
: {}),
|
|
4783
|
+
events: (Array.isArray(backend.events) ? backend.events : []).map((event) => {
|
|
4784
|
+
const value = objectOrEmpty(event);
|
|
4785
|
+
const payload = objectOrEmpty(value.payload ?? null);
|
|
4786
|
+
return {
|
|
4787
|
+
sequence: value.sequence ?? null,
|
|
4788
|
+
eventType: value.eventType ?? null,
|
|
4789
|
+
checkpointType: value.checkpointType ?? null,
|
|
4790
|
+
createdAt: value.createdAt ?? null,
|
|
4791
|
+
...(typeof payload.summary === 'string' ? { summary: payload.summary } : {}),
|
|
4792
|
+
};
|
|
4793
|
+
}),
|
|
4794
|
+
documents: includeDocuments ? documentViews(backend.documents) : [],
|
|
4795
|
+
};
|
|
4796
|
+
}
|
|
3793
4797
|
function archivedProjectGuidance(project) {
|
|
3794
4798
|
const projectId = typeof project?.id === 'string' ? project.id : null;
|
|
3795
4799
|
const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
|
|
@@ -3810,6 +4814,26 @@ function archivedProjectGuidance(project) {
|
|
|
3810
4814
|
nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
|
|
3811
4815
|
};
|
|
3812
4816
|
}
|
|
4817
|
+
function livesIn(project) {
|
|
4818
|
+
const repository = objectValue(project?.repository);
|
|
4819
|
+
return typeof repository?.repoFingerprint === 'string' ? repository.repoFingerprint : undefined;
|
|
4820
|
+
}
|
|
4821
|
+
function movedProjectGuidance(project, bound = false) {
|
|
4822
|
+
if (project?.checkout !== 'retired')
|
|
4823
|
+
return {};
|
|
4824
|
+
const home = objectValue(project.repository)?.remoteIdentity;
|
|
4825
|
+
return {
|
|
4826
|
+
projectMoved: true,
|
|
4827
|
+
movedTo: typeof home === 'string' ? home : null,
|
|
4828
|
+
nextAction: bound
|
|
4829
|
+
? `The project "${String(project.name)}" no longer lives in the repository this checkout points at` +
|
|
4830
|
+
(typeof home === 'string' ? `; it moved to ${home}` : '') +
|
|
4831
|
+
'. Tell the user before anything else. A task already open here can be resumed and finished. Do not call task.branch or open a new task here: new work starts from a checkout of the new repository, where the project is selected again. A project owner calls project.move_repository from here only when the code really lives in this repository again.'
|
|
4832
|
+
: `This checkout points at a repository the project "${String(project.name)}" left` +
|
|
4833
|
+
(typeof home === 'string' ? `; it now lives in ${home}` : '') +
|
|
4834
|
+
'. Tell the user. To keep working on that project, point this checkout at its new repository (or clone it) and select the project again. A project owner calls project.move_repository only when the code of the project really lives in this repository again. Do not create a second project for it.',
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
3813
4837
|
function slugify(value) {
|
|
3814
4838
|
return value
|
|
3815
4839
|
.trim()
|
|
@@ -3837,6 +4861,11 @@ function classifyError(error) {
|
|
|
3837
4861
|
}
|
|
3838
4862
|
return 'bridge_delivery_error';
|
|
3839
4863
|
}
|
|
4864
|
+
function backendRejectsField(error, fields) {
|
|
4865
|
+
return (error instanceof ApiResponseError &&
|
|
4866
|
+
error.httpStatus === 400 &&
|
|
4867
|
+
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
4868
|
+
}
|
|
3840
4869
|
function isDefinitiveAuthenticationFailure(error) {
|
|
3841
4870
|
return (error instanceof ApiResponseError &&
|
|
3842
4871
|
(error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
|
|
@@ -3914,24 +4943,60 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
3914
4943
|
return false;
|
|
3915
4944
|
}
|
|
3916
4945
|
}
|
|
3917
|
-
function
|
|
3918
|
-
return
|
|
4946
|
+
export function forgetUnreadableQuestionId(entry, decisionAttempt) {
|
|
4947
|
+
return ('worktree-forget-unreadable-' +
|
|
4948
|
+
sha256(stableStringify({
|
|
4949
|
+
projectId: entry.projectId,
|
|
4950
|
+
externalTaskId: entry.externalTaskId,
|
|
4951
|
+
repoRoot: entry.repoRoot,
|
|
4952
|
+
generation: entry.generation,
|
|
4953
|
+
pendingDelivery: entry.pendingDelivery,
|
|
4954
|
+
decisionAttempt,
|
|
4955
|
+
})));
|
|
4956
|
+
}
|
|
4957
|
+
export function cancelledDeliveryQuestionId(entry, clean) {
|
|
4958
|
+
return ('worktree-delivery-cancel-' +
|
|
4959
|
+
sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
|
|
4960
|
+
}
|
|
4961
|
+
function refuse(message, recovery, details) {
|
|
4962
|
+
return new BridgeRecoveryError(message, recovery, details);
|
|
3919
4963
|
}
|
|
3920
4964
|
function short(value) {
|
|
3921
4965
|
return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
|
|
3922
4966
|
}
|
|
3923
|
-
function
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4967
|
+
function samplePaths(paths) {
|
|
4968
|
+
return paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
|
|
4969
|
+
}
|
|
4970
|
+
const contextRefreshConflicts = [
|
|
4971
|
+
'context_refresh_required',
|
|
4972
|
+
'approved_memory_revision_requires_refresh',
|
|
4973
|
+
];
|
|
4974
|
+
function contextRefreshAction(staleReason, baseline) {
|
|
4975
|
+
const reason = objectValue(staleReason);
|
|
4976
|
+
const cause = reason && typeof reason.resourceKey === 'string'
|
|
4977
|
+
? `Approved revision ${String(reason.revisionNumber)} of ${reason.resourceKey}${typeof reason.taskExternalId === 'string' ? ` (from ${reason.taskExternalId})` : ''} changed memory this task read.`
|
|
4978
|
+
: 'Approved memory changed what this task read.';
|
|
4979
|
+
return `${cause} Call context.refresh, then context.prepare_change with ${baseline?.leasePaths.length ? baseline.leasePaths.join(', ') : 'the paths this task changes'}.${baseline ? ` The task baseline stays ${short(baseline.diffHash)}.` : ''}`;
|
|
4980
|
+
}
|
|
4981
|
+
function taskChangeSet(baseline, activeLease, current) {
|
|
4982
|
+
const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
|
|
4983
|
+
? activeLease.changedPaths.filter((path) => typeof path === 'string')
|
|
4984
|
+
: [];
|
|
4985
|
+
const wholeTreeDelta = manifestDelta(baseline.changedPaths, current);
|
|
4986
|
+
const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
|
|
4987
|
+
const taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
|
|
4988
|
+
return { leaseChangedPaths, wholeTreeDelta, leased, taskChanges };
|
|
4989
|
+
}
|
|
4990
|
+
function renderedPaths(paths, policy) {
|
|
4991
|
+
if (!policy)
|
|
4992
|
+
return [];
|
|
4993
|
+
return paths
|
|
4994
|
+
.filter((path) => policy.units.some((unit) => (unit.kind === 'screen_logic' || unit.kind === 'component_mapping') &&
|
|
4995
|
+
unit.patterns.some((pattern) => globMatches(path, pattern))))
|
|
4996
|
+
.sort();
|
|
4997
|
+
}
|
|
4998
|
+
function validationWaiverId(taskId, validationId, paths, reason) {
|
|
4999
|
+
return `validation-waiver-${validationId}-${sha256(stableStringify({ taskId, validationId, paths, reason })).slice(0, 32)}`;
|
|
3935
5000
|
}
|
|
3936
5001
|
function repairChangeBaseline(local, recordedDiffHash, head) {
|
|
3937
5002
|
if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
|
|
@@ -4122,7 +5187,7 @@ function globMatches(path, pattern) {
|
|
|
4122
5187
|
}
|
|
4123
5188
|
return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
|
|
4124
5189
|
}
|
|
4125
|
-
function
|
|
5190
|
+
function newResourceEvidenceFindings(candidates, evidence) {
|
|
4126
5191
|
const normalizedEvidence = evidence.map((entry) => ({
|
|
4127
5192
|
...entry,
|
|
4128
5193
|
path: normalizeChangedPaths([entry.path])[0],
|
|
@@ -4133,47 +5198,56 @@ function assertNewResourceEvidence(candidates, evidence) {
|
|
|
4133
5198
|
if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
|
|
4134
5199
|
throw refuse('New resource evidence contains duplicate paths', 'task.verify');
|
|
4135
5200
|
}
|
|
4136
|
-
const
|
|
4137
|
-
const
|
|
4138
|
-
|
|
4139
|
-
.
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
5201
|
+
const sent = new Map(normalizedEvidence.map((entry) => [entry.path, entry.kind]));
|
|
5202
|
+
const expected = new Map(candidates.map((entry) => [entry.path, entry.kind]));
|
|
5203
|
+
const missing = candidates
|
|
5204
|
+
.filter((entry) => !sent.has(entry.path))
|
|
5205
|
+
.map((entry) => `${entry.path} (${entry.kind})`)
|
|
5206
|
+
.sort();
|
|
5207
|
+
const extra = normalizedEvidence
|
|
5208
|
+
.filter((entry) => !expected.has(entry.path))
|
|
5209
|
+
.map((entry) => `${entry.path} (${entry.kind})`)
|
|
5210
|
+
.sort();
|
|
5211
|
+
const mismatched = normalizedEvidence
|
|
5212
|
+
.filter((entry) => expected.has(entry.path) && expected.get(entry.path) !== entry.kind)
|
|
5213
|
+
.map((entry) => `${entry.path} (sent ${entry.kind}, policy ${expected.get(entry.path)})`)
|
|
5214
|
+
.sort();
|
|
5215
|
+
const details = [
|
|
5216
|
+
missing.length ? `missing evidence for ${missing.join(', ')}` : '',
|
|
5217
|
+
extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
|
|
5218
|
+
mismatched.length
|
|
5219
|
+
? `kinds that do not match the project discovery policy: ${mismatched.join(', ')}`
|
|
5220
|
+
: '',
|
|
5221
|
+
].filter(Boolean);
|
|
5222
|
+
return details.length > 0
|
|
5223
|
+
? [
|
|
5224
|
+
`New screen or shared component paths require exact memory reconciliation. ${details.join('; ')}.`,
|
|
5225
|
+
]
|
|
5226
|
+
: [];
|
|
4155
5227
|
}
|
|
4156
|
-
function
|
|
5228
|
+
function validationEvidenceFindings(validations) {
|
|
4157
5229
|
if (validations.length === 0) {
|
|
4158
|
-
|
|
5230
|
+
return ['Validation evidence is required'];
|
|
4159
5231
|
}
|
|
4160
5232
|
const allowed = new Set(validationIds);
|
|
4161
5233
|
const seen = new Set();
|
|
5234
|
+
const findings = [];
|
|
4162
5235
|
for (const validation of validations) {
|
|
4163
5236
|
if (!allowed.has(validation.validationId)) {
|
|
4164
|
-
|
|
5237
|
+
findings.push(`Unknown validation evidence: ${validation.validationId}`);
|
|
4165
5238
|
}
|
|
4166
|
-
if (seen.has(validation.validationId)) {
|
|
4167
|
-
|
|
5239
|
+
else if (seen.has(validation.validationId)) {
|
|
5240
|
+
findings.push(`Duplicate validation evidence: ${validation.validationId}`);
|
|
4168
5241
|
}
|
|
4169
5242
|
if (!validation.command.trim()) {
|
|
4170
|
-
|
|
5243
|
+
findings.push(`Validation command is required: ${validation.validationId}`);
|
|
4171
5244
|
}
|
|
4172
5245
|
if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
|
|
4173
|
-
|
|
5246
|
+
findings.push(`Validation output hash is invalid: ${validation.validationId}`);
|
|
4174
5247
|
}
|
|
4175
5248
|
seen.add(validation.validationId);
|
|
4176
5249
|
}
|
|
5250
|
+
return findings;
|
|
4177
5251
|
}
|
|
4178
5252
|
function sourceFromContext(value) {
|
|
4179
5253
|
const context = objectValue(value);
|