engineering-memory 1.11.9 → 1.11.10
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 +6 -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 +1 -0
- package/runtime/dist/src/git/git-inspector.js +82 -3
- package/runtime/dist/src/git/verification-gate.js +30 -17
- package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
- package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
- package/runtime/dist/src/mcp/tool-annotations.js +111 -0
- package/runtime/dist/src/mcp/tool-definitions.js +148 -28
- package/runtime/dist/src/mcp/worktree-tools.js +323 -236
- package/runtime/dist/src/project/repository.js +11 -5
- package/runtime/dist/src/runtime/active-context-store.js +3 -0
- package/runtime/dist/src/runtime/branch-preferences.js +0 -19
- package/runtime/dist/src/runtime/bridge-service.js +1131 -220
- 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 +334 -24
- package/runtime/dist/src/utilities/process.js +1 -0
- package/skill/SKILL.md +3 -3
- package/skill/references/lifecycle.md +44 -19
- package/skill/references/memory-updates.md +9 -3
- package/skill/references/project-onboarding.md +19 -2
- package/skill/references/questionnaires.md +77 -15
|
@@ -8,10 +8,12 @@ import { ProjectIntake } from '../project/project-intake.js';
|
|
|
8
8
|
import { canonicalPath } from '../utilities/files.js';
|
|
9
9
|
import { minimatch } from 'minimatch';
|
|
10
10
|
import { endpoints } from '../config.js';
|
|
11
|
+
import { startPhaseTimer } from './phase-timer.js';
|
|
11
12
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
12
13
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
13
14
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
14
15
|
import { principalFingerprint } from './principal-state.js';
|
|
16
|
+
import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
|
|
15
17
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
16
18
|
export const validationIds = [
|
|
17
19
|
'format',
|
|
@@ -38,6 +40,7 @@ export class BridgeService {
|
|
|
38
40
|
deliveryQueues = new Map();
|
|
39
41
|
deliveryWaiters = [];
|
|
40
42
|
activeDeliveries = 0;
|
|
43
|
+
workedTasks = new Map();
|
|
41
44
|
constructor(dependencies) {
|
|
42
45
|
this.dependencies = dependencies;
|
|
43
46
|
this.deferDeliveries = dependencies.deferDeliveries ?? true;
|
|
@@ -45,10 +48,22 @@ export class BridgeService {
|
|
|
45
48
|
managesWorktrees() {
|
|
46
49
|
return Boolean(this.dependencies.worktreePool);
|
|
47
50
|
}
|
|
48
|
-
async questionnaireAsk(input, previousDefinitions = []) {
|
|
51
|
+
async questionnaireAsk(input, previousDefinitions = [], owner) {
|
|
49
52
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
50
|
-
const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
|
|
51
|
-
|
|
53
|
+
const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
|
|
54
|
+
const askingTask = owner || input.preparation
|
|
55
|
+
? null
|
|
56
|
+
: await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
|
|
57
|
+
return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
|
|
58
|
+
}
|
|
59
|
+
async checkoutTask(repoFingerprint, repoRoot) {
|
|
60
|
+
const worktreeId = sha256(await canonicalPath(repoRoot));
|
|
61
|
+
const task = this.workedTasks.get(worktreeId);
|
|
62
|
+
if (!task)
|
|
63
|
+
return null;
|
|
64
|
+
return (await this.dependencies.activeContexts.list(repoFingerprint)).some((pointer) => pointer.taskSlug === task && pointer.worktreeId === worktreeId)
|
|
65
|
+
? task
|
|
66
|
+
: null;
|
|
52
67
|
}
|
|
53
68
|
async questionnaireResume(input) {
|
|
54
69
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
@@ -66,18 +81,38 @@ export class BridgeService {
|
|
|
66
81
|
if (stableStringify(scope) !== stableStringify(input.scope)) {
|
|
67
82
|
throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
|
|
68
83
|
}
|
|
69
|
-
return await this.
|
|
84
|
+
return await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
|
|
85
|
+
}
|
|
86
|
+
async acceptWithScope(scope, questionnaireId, requestKey, answer, answerSource) {
|
|
87
|
+
return this.dependencies.questionnaires.accept(scope, questionnaireId, requestKey, answer, answerSource);
|
|
88
|
+
}
|
|
89
|
+
retryHint(record, repoRoot, attemptOffset = 0) {
|
|
90
|
+
if (!record.owner || record.owner.tool === 'questionnaire.ask')
|
|
91
|
+
return undefined;
|
|
92
|
+
const attempt = (record.owner.decisionAttempt ?? 0) + attemptOffset;
|
|
93
|
+
return {
|
|
94
|
+
tool: record.owner.tool,
|
|
95
|
+
arguments: {
|
|
96
|
+
...record.owner.retryArguments,
|
|
97
|
+
repoRoot,
|
|
98
|
+
...(record.owner.externalTaskId ? { externalTaskId: record.owner.externalTaskId } : {}),
|
|
99
|
+
...(attempt > 0 ? { decisionAttempt: attempt } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
70
102
|
}
|
|
71
103
|
async questionnaireAnswerFromHost(input) {
|
|
72
|
-
const
|
|
104
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
105
|
+
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
106
|
+
if (!record)
|
|
107
|
+
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
73
108
|
if (record.requestKey !== input.requestKey || record.contentHash !== input.contentHash) {
|
|
74
109
|
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
110
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
111
|
+
const resolved = await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, (input.answer ?? input.answers), { kind: 'host_native_relay', hostTool: input.hostTool });
|
|
112
|
+
return {
|
|
113
|
+
...resolved,
|
|
114
|
+
retry: this.retryHint(resolved.record, input.repoRoot ?? process.cwd()),
|
|
115
|
+
};
|
|
81
116
|
}
|
|
82
117
|
async questionnaireScope(repoRoot, preparation = false) {
|
|
83
118
|
await this.dependencies.principalState?.ensure();
|
|
@@ -99,7 +134,7 @@ export class BridgeService {
|
|
|
99
134
|
}
|
|
100
135
|
let repository;
|
|
101
136
|
try {
|
|
102
|
-
repository = await this.dependencies.repositories.
|
|
137
|
+
repository = await this.dependencies.repositories.resolveIdentity(lookup);
|
|
103
138
|
}
|
|
104
139
|
catch (error) {
|
|
105
140
|
if (!preparation ||
|
|
@@ -128,18 +163,64 @@ export class BridgeService {
|
|
|
128
163
|
projectId: preparation ? null : repository.projectId,
|
|
129
164
|
};
|
|
130
165
|
}
|
|
131
|
-
async
|
|
166
|
+
async questionnaireOverview(repoRoot, currentExternalTaskId, liveTaskSlugs = new Set()) {
|
|
132
167
|
const scope = await this.questionnaireScope(repoRoot);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
168
|
+
const records = await this.dependencies.questionnaires.list(scope);
|
|
169
|
+
const mine = [];
|
|
170
|
+
const other = [];
|
|
171
|
+
for (const record of records.filter((item) => item.status === 'pending')) {
|
|
172
|
+
const ownerTask = record.owner?.externalTaskId;
|
|
173
|
+
if (!ownerTask || ownerTask === currentExternalTaskId)
|
|
174
|
+
mine.push(asJsonValue({
|
|
175
|
+
questionnaireId: record.questionnaireId,
|
|
176
|
+
message: record.message,
|
|
177
|
+
...(record.questions
|
|
178
|
+
? {
|
|
179
|
+
questions: record.questions,
|
|
180
|
+
...(record.terminalChoices ? { terminalChoices: record.terminalChoices } : {}),
|
|
181
|
+
}
|
|
182
|
+
: {
|
|
183
|
+
options: record.options,
|
|
184
|
+
allowFreeText: record.allowFreeText,
|
|
185
|
+
...(record.textField ? { textField: record.textField } : {}),
|
|
186
|
+
}),
|
|
187
|
+
createdAt: record.createdAt,
|
|
188
|
+
status: record.status,
|
|
189
|
+
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.',
|
|
190
|
+
}));
|
|
191
|
+
else
|
|
192
|
+
other.push(asJsonValue({
|
|
193
|
+
questionnaireId: record.questionnaireId,
|
|
194
|
+
ownerTask,
|
|
195
|
+
createdAt: record.createdAt,
|
|
196
|
+
summary: record.message.slice(0, 120),
|
|
197
|
+
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.`,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
const latestStart = new Map();
|
|
201
|
+
for (const record of records) {
|
|
202
|
+
const task = record.owner?.tool === 'task.branch' ? record.owner.externalTaskId : undefined;
|
|
203
|
+
const current = task ? latestStart.get(task) : undefined;
|
|
204
|
+
if (task &&
|
|
205
|
+
record.status !== 'withdrawn' &&
|
|
206
|
+
(!current || (record.owner?.decisionAttempt ?? 0) >= (current.owner?.decisionAttempt ?? 0)))
|
|
207
|
+
latestStart.set(task, record);
|
|
208
|
+
}
|
|
209
|
+
const deferred = [...latestStart.entries()]
|
|
210
|
+
.filter(([task, record]) => !liveTaskSlugs.has(task) &&
|
|
211
|
+
(taskStartChoice(record)?.status === 'deferred' ||
|
|
212
|
+
(record.questionnaireId.startsWith('task-branch-fallback-') &&
|
|
213
|
+
record.status === 'answered' &&
|
|
214
|
+
record.answer?.choice === 'defer')))
|
|
215
|
+
.sort(([, left], [, right]) => (left.answeredAt ?? left.createdAt).localeCompare(right.answeredAt ?? right.createdAt))
|
|
216
|
+
.slice(-5)
|
|
217
|
+
.map(([externalTaskId, record]) => asJsonValue({
|
|
218
|
+
externalTaskId,
|
|
219
|
+
deferredAt: record.answeredAt ?? record.createdAt,
|
|
220
|
+
reconsider: this.retryHint(record, repoRoot, 1),
|
|
221
|
+
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
222
|
}));
|
|
223
|
+
return { mine, other, deferred };
|
|
143
224
|
}
|
|
144
225
|
deliverInBackground(taskId) {
|
|
145
226
|
if (!this.deferDeliveries)
|
|
@@ -274,6 +355,10 @@ export class BridgeService {
|
|
|
274
355
|
poolAllocation.taskId = task.id;
|
|
275
356
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
276
357
|
}
|
|
358
|
+
const replayedPointer = existingTask?.taskId === task.id ? existingTask : null;
|
|
359
|
+
const recordedBaselineDiffHash = typeof data.changeBaselineDiffHash === 'string' ? data.changeBaselineDiffHash : undefined;
|
|
360
|
+
const repairedBaseline = repairChangeBaseline(replayedPointer?.changeBaseline, recordedBaselineDiffHash, repository.git.head);
|
|
361
|
+
const replayedBaseline = repairedBaseline ?? replayedPointer?.changeBaseline;
|
|
277
362
|
const pointer = {
|
|
278
363
|
repoFingerprint: repository.repoFingerprint,
|
|
279
364
|
projectId,
|
|
@@ -289,9 +374,21 @@ export class BridgeService {
|
|
|
289
374
|
sessionId: session.id,
|
|
290
375
|
lastSequence: numericSequence(task.lastSequence),
|
|
291
376
|
taskVersion: numericTaskVersion(task.lockVersion),
|
|
292
|
-
resumeConflicts: [
|
|
377
|
+
resumeConflicts: [
|
|
378
|
+
...new Set([
|
|
379
|
+
...(replayedPointer?.resumeConflicts.filter((conflict) => contextRefreshConflicts.includes(conflict)) ?? []),
|
|
380
|
+
...(data.requiresContextRefresh === true ? ['context_refresh_required'] : []),
|
|
381
|
+
...(recordedBaselineDiffHash &&
|
|
382
|
+
replayedBaseline &&
|
|
383
|
+
replayedBaseline.diffHash !== recordedBaselineDiffHash
|
|
384
|
+
? ['task_change_baseline_recaptured']
|
|
385
|
+
: []),
|
|
386
|
+
]),
|
|
387
|
+
],
|
|
388
|
+
...(repairedBaseline ? { changeBaseline: repairedBaseline } : {}),
|
|
293
389
|
};
|
|
294
390
|
await this.dependencies.activeContexts.save(pointer);
|
|
391
|
+
this.workedTasks.set(sha256(repository.repoRoot), input.externalTaskId);
|
|
295
392
|
await this.dependencies.journal.apply({
|
|
296
393
|
eventId: checkpointId,
|
|
297
394
|
taskId: task.id,
|
|
@@ -312,9 +409,10 @@ export class BridgeService {
|
|
|
312
409
|
await this.seedResumeSnapshot(pointer, data);
|
|
313
410
|
return asJsonValue({
|
|
314
411
|
...data,
|
|
412
|
+
checkpoint: checkpointView(checkpoint),
|
|
315
413
|
repository: publicRepository(repository),
|
|
316
|
-
localJournal: await this.dependencies.journal.load(projectId, input.externalTaskId),
|
|
317
|
-
outbox: { recoveredDeliveries, ...
|
|
414
|
+
localJournal: journalSummary(await this.dependencies.journal.load(projectId, input.externalTaskId)),
|
|
415
|
+
outbox: { recoveredDeliveries, ...outboxSummary(outbox) },
|
|
318
416
|
});
|
|
319
417
|
});
|
|
320
418
|
}
|
|
@@ -328,12 +426,12 @@ export class BridgeService {
|
|
|
328
426
|
nextAction: entryNextAction(false, 'disabled'),
|
|
329
427
|
});
|
|
330
428
|
}
|
|
331
|
-
const
|
|
429
|
+
const earlyPendingQuestionnaires = await this.questionnaireOverview(repository.repoRoot, input.taskSlug ?? null);
|
|
332
430
|
const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
|
|
333
|
-
if (!signedIn &&
|
|
431
|
+
if (!signedIn && earlyPendingQuestionnaires.mine.length > 0) {
|
|
334
432
|
return asJsonValue({
|
|
335
433
|
authenticated: false,
|
|
336
|
-
|
|
434
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
337
435
|
repository: publicRepository(repository),
|
|
338
436
|
nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
|
|
339
437
|
});
|
|
@@ -357,7 +455,7 @@ export class BridgeService {
|
|
|
357
455
|
return asJsonValue({
|
|
358
456
|
taskChoiceRequired: true,
|
|
359
457
|
requestedTaskSlug: input.taskSlug,
|
|
360
|
-
|
|
458
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
361
459
|
liveTasks: live.map(describePointer),
|
|
362
460
|
repository: publicRepository(repository),
|
|
363
461
|
});
|
|
@@ -366,7 +464,7 @@ export class BridgeService {
|
|
|
366
464
|
return asJsonValue({
|
|
367
465
|
taskChoiceRequired: true,
|
|
368
466
|
requestedSessionId: input.sessionId,
|
|
369
|
-
|
|
467
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
370
468
|
liveTasks: live.map(describePointer),
|
|
371
469
|
repository: publicRepository(repository),
|
|
372
470
|
});
|
|
@@ -374,7 +472,7 @@ export class BridgeService {
|
|
|
374
472
|
if (!pointer && live.length > 1) {
|
|
375
473
|
return asJsonValue({
|
|
376
474
|
taskChoiceRequired: true,
|
|
377
|
-
|
|
475
|
+
...pendingQuestionnairesFields(earlyPendingQuestionnaires),
|
|
378
476
|
liveTasks: live.map(describePointer),
|
|
379
477
|
repository: publicRepository(repository),
|
|
380
478
|
});
|
|
@@ -382,10 +480,13 @@ export class BridgeService {
|
|
|
382
480
|
const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
|
|
383
481
|
const taskSlug = input.taskSlug ?? pointer?.taskSlug;
|
|
384
482
|
const sessionId = input.sessionId ?? pointer?.sessionId;
|
|
483
|
+
const pendingQuestionnaires = taskSlug && taskSlug !== (input.taskSlug ?? null)
|
|
484
|
+
? await this.questionnaireOverview(repository.repoRoot, taskSlug)
|
|
485
|
+
: earlyPendingQuestionnaires;
|
|
385
486
|
if (!projectId || !taskSlug || !sessionId) {
|
|
386
|
-
if (pendingQuestionnaires.length > 0) {
|
|
487
|
+
if (pendingQuestionnaires.mine.length > 0) {
|
|
387
488
|
return asJsonValue({
|
|
388
|
-
pendingQuestionnaires,
|
|
489
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
389
490
|
repository: publicRepository(repository),
|
|
390
491
|
nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
|
|
391
492
|
});
|
|
@@ -405,7 +506,17 @@ export class BridgeService {
|
|
|
405
506
|
this.dependencies.worktreePool
|
|
406
507
|
? await this.dependencies.worktreePool.find(projectId, taskSlug, repository.repoRoot)
|
|
407
508
|
: null;
|
|
408
|
-
|
|
509
|
+
const parked = pointer &&
|
|
510
|
+
!pointer.worktreeId &&
|
|
511
|
+
!pointer.worktreeGeneration &&
|
|
512
|
+
!pointer.closedAt &&
|
|
513
|
+
normalizeTaskMode(pointer.mode) !== 'read_only' &&
|
|
514
|
+
typeof pointer.branch === 'string' &&
|
|
515
|
+
(await this.dependencies.repositories.git.currentBranch(repository.repoRoot)) !==
|
|
516
|
+
pointer.branch;
|
|
517
|
+
if (pointer &&
|
|
518
|
+
this.dependencies.worktreePool &&
|
|
519
|
+
(pointer.worktreeGeneration || adoptable || parked)) {
|
|
409
520
|
const pool = this.dependencies.worktreePool;
|
|
410
521
|
const previous = adoptable ?? (await pool.find(projectId, taskSlug, repository.repoRoot));
|
|
411
522
|
if (previous && previous.repoRoot !== repository.repoRoot)
|
|
@@ -416,10 +527,14 @@ export class BridgeService {
|
|
|
416
527
|
sessionId,
|
|
417
528
|
nextAction: 'Call session.resume from this returned repoRoot and use it for every subsequent command.',
|
|
418
529
|
});
|
|
419
|
-
const sourceCommit = previous?.baseCommit ?? pointer.source?.sourceCommit;
|
|
420
530
|
const branch = pointer.worktreeGeneration
|
|
421
531
|
? pointer.branch
|
|
422
532
|
: (previous?.branch ?? pointer.branch);
|
|
533
|
+
const sourceCommit = previous?.baseCommit ??
|
|
534
|
+
pointer.source?.sourceCommit ??
|
|
535
|
+
(parked && branch
|
|
536
|
+
? (await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot, 'refs/heads/' + branch))?.sourceCommit
|
|
537
|
+
: undefined);
|
|
423
538
|
if ((!sourceCommit && !(previous && previous.baseCommit === null && !previous.managed)) ||
|
|
424
539
|
branch === undefined)
|
|
425
540
|
throw refuse('The task branch and base must be restored before resuming its worktree.', 'worktree.reconcile');
|
|
@@ -455,12 +570,13 @@ export class BridgeService {
|
|
|
455
570
|
});
|
|
456
571
|
await this.dependencies.gate.rebindWorktreeOwnership(repository.repoRoot, pointer.taskId, oldGeneration, allocation.generation);
|
|
457
572
|
}
|
|
573
|
+
const checkout = await this.taskRepository(repository, pointer);
|
|
458
574
|
if (pointer?.closeIntent) {
|
|
459
|
-
const recoveredClose = await this.retryCloseIntent(
|
|
575
|
+
const recoveredClose = await this.retryCloseIntent(checkout, pointer);
|
|
460
576
|
return asJsonValue({
|
|
461
577
|
backend: { close: recoveredClose },
|
|
462
578
|
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
463
|
-
pendingQuestionnaires,
|
|
579
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
464
580
|
repository: publicRepository(repository),
|
|
465
581
|
localJournal: await this.dependencies.journal.load(projectId, taskSlug),
|
|
466
582
|
synchronization: {
|
|
@@ -474,7 +590,7 @@ export class BridgeService {
|
|
|
474
590
|
const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
|
|
475
591
|
const outbox = await this.flushOutbox(pointer?.taskId);
|
|
476
592
|
const afterSequence = input.afterSequence ?? 0;
|
|
477
|
-
const source = await this.checkoutSource(
|
|
593
|
+
const source = await this.checkoutSource(checkout, pointer?.source, pointer ?? undefined);
|
|
478
594
|
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
479
595
|
method: 'POST',
|
|
480
596
|
cacheKey: resumeCacheKey(sessionId, repository.repoFingerprint, afterSequence),
|
|
@@ -512,7 +628,7 @@ export class BridgeService {
|
|
|
512
628
|
let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
|
|
513
629
|
const responseSource = this.dependencies.client.getResponseSource(response);
|
|
514
630
|
const recoveredVerification = responseSource !== 'stale_cache' && pointer
|
|
515
|
-
? await this.retryVerificationRecovery(
|
|
631
|
+
? await this.retryVerificationRecovery(checkout, pointer, backend)
|
|
516
632
|
: null;
|
|
517
633
|
if (recoveredVerification) {
|
|
518
634
|
pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
|
|
@@ -576,6 +692,11 @@ export class BridgeService {
|
|
|
576
692
|
? await this.dependencies.journal.readDocuments(projectId, taskSlug)
|
|
577
693
|
: {};
|
|
578
694
|
const conflicts = resumeConflicts(backend, backendTask, pointer, localJournal, localDocuments, pendingOutbox, responseSource);
|
|
695
|
+
if (backend.requiresContextRefresh === true)
|
|
696
|
+
conflicts.push('context_refresh_required');
|
|
697
|
+
if (pointer?.resumeConflicts.includes('approved_memory_revision_requires_refresh'))
|
|
698
|
+
conflicts.push('approved_memory_revision_requires_refresh');
|
|
699
|
+
const contextRefreshRequired = conflicts.some((conflict) => contextRefreshConflicts.includes(conflict));
|
|
579
700
|
const resumedLease = objectValue(backend.activeLease);
|
|
580
701
|
const resumeIdentityMatches = pointer !== null &&
|
|
581
702
|
pointer.repoFingerprint === repository.repoFingerprint &&
|
|
@@ -607,11 +728,12 @@ export class BridgeService {
|
|
|
607
728
|
const resumedTaskVersion = responseSource === 'stale_cache' && pointer
|
|
608
729
|
? Math.max(pointer.taskVersion, numericTaskVersion(backendTask.lockVersion))
|
|
609
730
|
: numericTaskVersion(backendTask.lockVersion);
|
|
610
|
-
const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict));
|
|
731
|
+
const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict) && !contextRefreshConflicts.includes(conflict));
|
|
611
732
|
if (pointer && (!resumeIdentityMatches || hasBlockingConflict)) {
|
|
612
733
|
await this.dependencies.activeContexts.save({ ...pointer, resumeConflicts: conflicts });
|
|
613
734
|
}
|
|
614
735
|
else {
|
|
736
|
+
this.workedTasks.set(sha256(repository.repoRoot), taskSlug);
|
|
615
737
|
await this.dependencies.activeContexts.save({
|
|
616
738
|
repoFingerprint: repository.repoFingerprint,
|
|
617
739
|
projectId,
|
|
@@ -637,22 +759,33 @@ export class BridgeService {
|
|
|
637
759
|
? { verificationIntent: pointer.verificationIntent }
|
|
638
760
|
: {}),
|
|
639
761
|
...(pointer?.closeIntent ? { closeIntent: pointer.closeIntent } : {}),
|
|
762
|
+
...(backendTask.status === 'closed'
|
|
763
|
+
? { closedAt: pointer?.closedAt ?? new Date().toISOString() }
|
|
764
|
+
: {}),
|
|
640
765
|
});
|
|
641
766
|
}
|
|
767
|
+
const backendFresh = responseSource !== 'stale_cache';
|
|
642
768
|
return asJsonValue({
|
|
643
|
-
backend,
|
|
769
|
+
backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
|
|
770
|
+
requirements: backend.requirements ?? null,
|
|
771
|
+
...(contextRefreshRequired
|
|
772
|
+
? {
|
|
773
|
+
staleReason: backend.staleReason ?? null,
|
|
774
|
+
nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
|
|
775
|
+
}
|
|
776
|
+
: {}),
|
|
644
777
|
repository: publicRepository(repository),
|
|
645
778
|
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
646
|
-
localJournal,
|
|
647
|
-
pendingQuestionnaires,
|
|
779
|
+
localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
|
|
780
|
+
...pendingQuestionnairesFields(pendingQuestionnaires),
|
|
648
781
|
synchronization: {
|
|
649
782
|
backendSource: responseSource,
|
|
650
|
-
backendFresh
|
|
783
|
+
backendFresh,
|
|
651
784
|
hydratedFromBackend,
|
|
652
785
|
mergedFromBackend,
|
|
653
786
|
blockedJournalEventIds,
|
|
654
787
|
recoveredDeliveries,
|
|
655
|
-
outbox,
|
|
788
|
+
outbox: outboxSummary(outbox),
|
|
656
789
|
pendingOutboxCount: pendingOutbox.length,
|
|
657
790
|
pendingOutbox: pendingOutbox.map((entry) => ({
|
|
658
791
|
id: entry.id,
|
|
@@ -670,6 +803,15 @@ export class BridgeService {
|
|
|
670
803
|
(conflicts.length === 0 || offlineDevelopmentAllowed),
|
|
671
804
|
offlineDevelopmentOnly: offlineDevelopmentAllowed,
|
|
672
805
|
recoveredVerification,
|
|
806
|
+
...(checkout.git.baseCommit
|
|
807
|
+
? {
|
|
808
|
+
taskWorkCommitted: {
|
|
809
|
+
baseCommit: checkout.git.baseCommit,
|
|
810
|
+
headCommit: checkout.git.head,
|
|
811
|
+
branch: pointer?.branch ?? null,
|
|
812
|
+
},
|
|
813
|
+
}
|
|
814
|
+
: {}),
|
|
673
815
|
},
|
|
674
816
|
});
|
|
675
817
|
});
|
|
@@ -720,9 +862,44 @@ export class BridgeService {
|
|
|
720
862
|
conflicts: [...new Set(blockingConflicts)],
|
|
721
863
|
outbox,
|
|
722
864
|
repository: publicRepository(repository),
|
|
865
|
+
...(blockingConflicts.some((conflict) => contextRefreshConflicts.includes(conflict))
|
|
866
|
+
? { nextAction: contextRefreshAction(undefined, pointer.changeBaseline) }
|
|
867
|
+
: {}),
|
|
723
868
|
});
|
|
724
869
|
}
|
|
725
|
-
|
|
870
|
+
let activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
|
|
871
|
+
if (!activeBaseline) {
|
|
872
|
+
const snapshot = objectValue((await this.dependencies.client.request(endpoints.sessionResume, {
|
|
873
|
+
method: 'POST',
|
|
874
|
+
cacheKey: resumeCacheKey(input.sessionId, repository.repoFingerprint, 0),
|
|
875
|
+
allowStaleOnUnavailable: true,
|
|
876
|
+
body: {
|
|
877
|
+
sessionId: input.sessionId,
|
|
878
|
+
repoFingerprint: repository.repoFingerprint,
|
|
879
|
+
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
880
|
+
afterSequence: 0,
|
|
881
|
+
},
|
|
882
|
+
})).data);
|
|
883
|
+
const recordedBaselineDiffHash = typeof snapshot?.changeBaselineDiffHash === 'string'
|
|
884
|
+
? snapshot.changeBaselineDiffHash
|
|
885
|
+
: undefined;
|
|
886
|
+
if (recordedBaselineDiffHash) {
|
|
887
|
+
activeBaseline = repairChangeBaseline(undefined, recordedBaselineDiffHash, repository.git.head);
|
|
888
|
+
if (!activeBaseline) {
|
|
889
|
+
return asJsonValue({
|
|
890
|
+
editLeaseGranted: false,
|
|
891
|
+
conflicts: ['task_change_baseline_recaptured'],
|
|
892
|
+
outbox,
|
|
893
|
+
repository: publicRepository(repository),
|
|
894
|
+
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.`,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
await this.dependencies.activeContexts.save({
|
|
898
|
+
...pointer,
|
|
899
|
+
changeBaseline: activeBaseline,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
726
903
|
const changedPaths = normalizeChangedPaths([
|
|
727
904
|
...(activeBaseline?.leasePaths ?? []),
|
|
728
905
|
...input.changedPaths,
|
|
@@ -750,7 +927,7 @@ export class BridgeService {
|
|
|
750
927
|
sessionId: input.sessionId,
|
|
751
928
|
changedPaths,
|
|
752
929
|
baselineDiffHash,
|
|
753
|
-
source: await this.checkoutSource(repository, pointer
|
|
930
|
+
source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
|
|
754
931
|
...(decision ? { branch: decision.branch } : {}),
|
|
755
932
|
...(input.transitionToWrite
|
|
756
933
|
? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
|
|
@@ -962,13 +1139,14 @@ export class BridgeService {
|
|
|
962
1139
|
if (pointer && repository && pointer.mode !== 'read_only')
|
|
963
1140
|
await this.assertPoolPointer(repository, pointer);
|
|
964
1141
|
const source = repository
|
|
965
|
-
? await this.checkoutSource(repository, pointer?.source)
|
|
1142
|
+
? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
|
|
966
1143
|
: undefined;
|
|
967
1144
|
const response = await this.dependencies.client.request(endpoints.contextRefresh, {
|
|
968
1145
|
method: 'POST',
|
|
969
1146
|
body: cleanJson({ sessionId: input.sessionId, source }),
|
|
970
1147
|
});
|
|
971
|
-
|
|
1148
|
+
for (const conflict of contextRefreshConflicts)
|
|
1149
|
+
await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, conflict);
|
|
972
1150
|
return asJsonValue({
|
|
973
1151
|
...objectOrEmpty(response.data),
|
|
974
1152
|
requiresPrepareChange: true,
|
|
@@ -984,7 +1162,9 @@ export class BridgeService {
|
|
|
984
1162
|
cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
|
|
985
1163
|
allowStaleOnUnavailable: true,
|
|
986
1164
|
});
|
|
987
|
-
return
|
|
1165
|
+
return Array.isArray(response.data)
|
|
1166
|
+
? asJsonValue({ resources: response.data })
|
|
1167
|
+
: response.data;
|
|
988
1168
|
});
|
|
989
1169
|
}
|
|
990
1170
|
async memoryHistory(input) {
|
|
@@ -992,15 +1172,31 @@ export class BridgeService {
|
|
|
992
1172
|
const body = cleanJson({
|
|
993
1173
|
sessionId: input.sessionId,
|
|
994
1174
|
resourceKeys: input.resourceKeys,
|
|
1175
|
+
resourceIds: input.resourceIds,
|
|
995
1176
|
paths: input.paths,
|
|
1177
|
+
required: input.required,
|
|
996
1178
|
depth: input.depth,
|
|
997
1179
|
});
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1180
|
+
let response;
|
|
1181
|
+
try {
|
|
1182
|
+
response = await this.dependencies.client.request(endpoints.memoryHistory, {
|
|
1183
|
+
method: 'POST',
|
|
1184
|
+
body,
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
if (backendRejectsField(error, ['required', 'resourceIds']))
|
|
1189
|
+
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');
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
1192
|
+
const data = objectValue(response.data);
|
|
1002
1193
|
const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
|
|
1003
|
-
return asJsonValue({
|
|
1194
|
+
return asJsonValue({
|
|
1195
|
+
history: Array.isArray(response.data) ? response.data : (data?.histories ?? []),
|
|
1196
|
+
uncoveredPaths: data?.uncoveredPaths ?? [],
|
|
1197
|
+
historyReadsRequired: data?.historyReadsRequired ?? [],
|
|
1198
|
+
relatedBranches,
|
|
1199
|
+
});
|
|
1004
1200
|
});
|
|
1005
1201
|
}
|
|
1006
1202
|
async branchTaskReferences(repoRoot, paths) {
|
|
@@ -1341,7 +1537,7 @@ export class BridgeService {
|
|
|
1341
1537
|
async memoryListProposals(input) {
|
|
1342
1538
|
return await this.execute(async () => {
|
|
1343
1539
|
const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
|
|
1344
|
-
return asJsonValue(
|
|
1540
|
+
return asJsonValue(response.data);
|
|
1345
1541
|
});
|
|
1346
1542
|
}
|
|
1347
1543
|
async memoryReviewProposal(input) {
|
|
@@ -1416,16 +1612,25 @@ export class BridgeService {
|
|
|
1416
1612
|
}
|
|
1417
1613
|
return [...pending];
|
|
1418
1614
|
}
|
|
1615
|
+
ruleDeviationQuestions(input) {
|
|
1616
|
+
const language = input.language ?? 'en';
|
|
1617
|
+
return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
|
|
1618
|
+
definition: ruleDeviationQuestion(input.taskId, path, rule, language),
|
|
1619
|
+
previousDefinitions: ['en', 'tr']
|
|
1620
|
+
.filter((other) => other !== language)
|
|
1621
|
+
.map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
|
|
1622
|
+
})));
|
|
1623
|
+
}
|
|
1419
1624
|
async taskSelfReview(input) {
|
|
1420
1625
|
return await this.execute(async () => {
|
|
1421
|
-
const
|
|
1422
|
-
assertSafeToPersist(cleanJson({
|
|
1423
|
-
const
|
|
1626
|
+
const files = await this.approvedDeviations(input);
|
|
1627
|
+
assertSafeToPersist(cleanJson({ files }));
|
|
1628
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1629
|
+
const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
|
|
1424
1630
|
if (this.deferDeliveries) {
|
|
1425
1631
|
return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
|
|
1426
1632
|
diffHash: repository.git.diffHash,
|
|
1427
|
-
|
|
1428
|
-
findings,
|
|
1633
|
+
files,
|
|
1429
1634
|
}));
|
|
1430
1635
|
}
|
|
1431
1636
|
const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
|
|
@@ -1435,20 +1640,50 @@ export class BridgeService {
|
|
|
1435
1640
|
taskId: input.taskId,
|
|
1436
1641
|
expectedTaskVersion: snapshot.taskVersion,
|
|
1437
1642
|
diffHash: repository.git.diffHash,
|
|
1438
|
-
|
|
1439
|
-
findings,
|
|
1643
|
+
files,
|
|
1440
1644
|
}),
|
|
1441
1645
|
});
|
|
1442
1646
|
await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
|
|
1443
1647
|
return asJsonValue({
|
|
1444
1648
|
selfReview: {
|
|
1445
1649
|
diffHash: repository.git.diffHash,
|
|
1446
|
-
|
|
1447
|
-
|
|
1650
|
+
files: files.map(({ path, rules }) => ({
|
|
1651
|
+
path,
|
|
1652
|
+
rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
|
|
1653
|
+
})),
|
|
1448
1654
|
},
|
|
1449
1655
|
});
|
|
1450
1656
|
});
|
|
1451
1657
|
}
|
|
1658
|
+
async approvedDeviations(input) {
|
|
1659
|
+
const files = reviewedFiles(input);
|
|
1660
|
+
if (!files.some(({ rules }) => rules.some(isDeviation)))
|
|
1661
|
+
return files;
|
|
1662
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
1663
|
+
return await Promise.all(files.map(async ({ path, rules }) => ({
|
|
1664
|
+
path,
|
|
1665
|
+
rules: await Promise.all(rules.map(async (rule) => {
|
|
1666
|
+
if (!isDeviation(rule))
|
|
1667
|
+
return rule;
|
|
1668
|
+
const questionnaireId = ruleDeviationQuestionnaireId(input.taskId, path, rule);
|
|
1669
|
+
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
1670
|
+
const choice = question?.status === 'answered' ? question.answer?.choice : undefined;
|
|
1671
|
+
if (choice === 'change')
|
|
1672
|
+
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');
|
|
1673
|
+
if (choice !== 'approve' || !question?.answeredAt)
|
|
1674
|
+
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');
|
|
1675
|
+
return {
|
|
1676
|
+
...rule,
|
|
1677
|
+
questionnaireId,
|
|
1678
|
+
deviationApproval: {
|
|
1679
|
+
questionnaireId,
|
|
1680
|
+
answeredAt: question.answeredAt,
|
|
1681
|
+
hostTool: question.answerSource?.hostTool ?? null,
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
})),
|
|
1685
|
+
})));
|
|
1686
|
+
}
|
|
1452
1687
|
async taskReconcile(input) {
|
|
1453
1688
|
return await this.execute(async () => {
|
|
1454
1689
|
const repoRoot = input.repoRoot ?? process.cwd();
|
|
@@ -1567,7 +1802,7 @@ export class BridgeService {
|
|
|
1567
1802
|
method: 'POST',
|
|
1568
1803
|
body: cleanJson({
|
|
1569
1804
|
sessionId: pointer.sessionId,
|
|
1570
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
1805
|
+
source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
|
|
1571
1806
|
}),
|
|
1572
1807
|
});
|
|
1573
1808
|
const refreshData = objectValue(refreshed.data);
|
|
@@ -1592,13 +1827,18 @@ export class BridgeService {
|
|
|
1592
1827
|
}
|
|
1593
1828
|
async taskVerify(input) {
|
|
1594
1829
|
return await this.execute(async () => {
|
|
1830
|
+
const timer = startPhaseTimer('task.verify');
|
|
1595
1831
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1596
1832
|
await this.recoverJournalOutbox();
|
|
1833
|
+
timer.mark('recover_journal_outbox');
|
|
1597
1834
|
const outbox = await this.flushOutbox(input.taskId);
|
|
1835
|
+
timer.mark('flush_outbox');
|
|
1598
1836
|
const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
|
|
1599
1837
|
.length;
|
|
1600
|
-
|
|
1838
|
+
let repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1839
|
+
timer.mark('resolve_repository');
|
|
1601
1840
|
if (pendingOutboxCount > 0) {
|
|
1841
|
+
timer.finish();
|
|
1602
1842
|
return asJsonValue({
|
|
1603
1843
|
verified: false,
|
|
1604
1844
|
reason: 'pending_outbox',
|
|
@@ -1608,6 +1848,7 @@ export class BridgeService {
|
|
|
1608
1848
|
});
|
|
1609
1849
|
}
|
|
1610
1850
|
const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
|
|
1851
|
+
repository = await this.taskRepository(repository, pointer);
|
|
1611
1852
|
const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
|
|
1612
1853
|
if (pendingCorrectionScopes.length > 0) {
|
|
1613
1854
|
return asJsonValue({
|
|
@@ -1640,7 +1881,7 @@ export class BridgeService {
|
|
|
1640
1881
|
body: {
|
|
1641
1882
|
sessionId: input.sessionId,
|
|
1642
1883
|
repoFingerprint: repository.repoFingerprint,
|
|
1643
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
1884
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1644
1885
|
afterSequence: 0,
|
|
1645
1886
|
},
|
|
1646
1887
|
});
|
|
@@ -1661,6 +1902,10 @@ export class BridgeService {
|
|
|
1661
1902
|
await this.assertTaskWorktree(repository, snapshotTask, pointer);
|
|
1662
1903
|
const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
|
|
1663
1904
|
const validations = normalizePersistentInput(input.validations, repository.repoRoot);
|
|
1905
|
+
const waivers = normalizePersistentInput(input.waivers ?? [], repository.repoRoot);
|
|
1906
|
+
if (snapshot?.requiresContextRefresh === true) {
|
|
1907
|
+
throw refuse(contextRefreshAction(snapshot.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
1908
|
+
}
|
|
1664
1909
|
if (taskMode === 'read_only') {
|
|
1665
1910
|
if (input.leaseId || input.changedPaths?.length) {
|
|
1666
1911
|
throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
|
|
@@ -1668,48 +1913,114 @@ export class BridgeService {
|
|
|
1668
1913
|
}
|
|
1669
1914
|
else {
|
|
1670
1915
|
const baseline = pointer.changeBaseline;
|
|
1671
|
-
const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
|
|
1672
|
-
? activeLease.changedPaths.filter((path) => typeof path === 'string')
|
|
1673
|
-
: [];
|
|
1674
1916
|
if (!baseline) {
|
|
1675
1917
|
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
1918
|
}
|
|
1677
|
-
if (activeLease
|
|
1678
|
-
throw refuse(`
|
|
1919
|
+
if (!activeLease) {
|
|
1920
|
+
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');
|
|
1921
|
+
}
|
|
1922
|
+
if (activeLease.baselineDiffHash !== baseline.diffHash) {
|
|
1923
|
+
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
1924
|
}
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
.filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
|
|
1925
|
+
const changeSet = taskChangeSet(baseline, activeLease, repository.git.changedPaths);
|
|
1926
|
+
taskChanges = changeSet.taskChanges;
|
|
1927
|
+
foreignChangedPaths = changeSet.wholeTreeDelta
|
|
1928
|
+
.filter((entry) => !changeSet.leased.has(normalizeChangedPaths([entry.path])[0]))
|
|
1685
1929
|
.map((entry) => entry.path)
|
|
1686
1930
|
.sort();
|
|
1687
1931
|
taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1932
|
+
const reported = input.changedPaths
|
|
1933
|
+
? normalizeChangedPaths(input.changedPaths)
|
|
1934
|
+
: taskChangedPaths;
|
|
1935
|
+
const treePaths = new Set(normalizeChangedPaths(changeSet.wholeTreeDelta.map((entry) => entry.path)));
|
|
1936
|
+
const outsideLease = [
|
|
1937
|
+
...new Set([
|
|
1938
|
+
...reported.filter((path) => treePaths.has(path) && !changeSet.leased.has(path)),
|
|
1939
|
+
...taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
|
|
1940
|
+
!pathsContainAll(changeSet.leaseChangedPaths, [path])),
|
|
1941
|
+
]),
|
|
1942
|
+
].sort();
|
|
1943
|
+
const notChanged = reported.filter((path) => !treePaths.has(path));
|
|
1944
|
+
const unreported = taskChangedPaths.filter((path) => !reported.includes(path));
|
|
1945
|
+
const baselineMoved = typeof snapshot?.changeBaselineDiffHash === 'string' &&
|
|
1946
|
+
snapshot.changeBaselineDiffHash !== baseline.diffHash;
|
|
1947
|
+
const pathFindings = [
|
|
1948
|
+
...(outsideLease.length > 0
|
|
1949
|
+
? [
|
|
1950
|
+
`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)}.`,
|
|
1951
|
+
]
|
|
1952
|
+
: []),
|
|
1953
|
+
...(notChanged.length > 0
|
|
1954
|
+
? [
|
|
1955
|
+
`These reported paths do not differ from the tree this task started from (baseline ${short(baseline.diffHash)}): ${samplePaths(notChanged)}. ${baselineMoved
|
|
1956
|
+
? 'The baseline has moved from the one the task recorded; resuming repairs it.'
|
|
1957
|
+
: 'Report only the paths the manifest lists.'}`,
|
|
1958
|
+
]
|
|
1959
|
+
: []),
|
|
1960
|
+
...(unreported.length > 0
|
|
1961
|
+
? [
|
|
1962
|
+
`This task changed these paths but did not report them: ${samplePaths(unreported)}. Report every path the manifest lists.`,
|
|
1963
|
+
]
|
|
1964
|
+
: []),
|
|
1965
|
+
];
|
|
1966
|
+
if (pathFindings.length > 0) {
|
|
1967
|
+
throw refuse(pathFindings.join(' '), outsideLease.length > 0
|
|
1968
|
+
? 'context.prepare_change'
|
|
1969
|
+
: notChanged.length > 0 && baselineMoved
|
|
1970
|
+
? 'session.resume'
|
|
1971
|
+
: 'task.verify', {
|
|
1972
|
+
outsideLease,
|
|
1973
|
+
notChanged,
|
|
1974
|
+
unreported,
|
|
1975
|
+
changedPathsOutsideThisTask: foreignChangedPaths,
|
|
1976
|
+
});
|
|
1692
1977
|
}
|
|
1693
1978
|
if (taskChangedPaths.length === 0) {
|
|
1694
1979
|
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
1980
|
}
|
|
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');
|
|
1981
|
+
if (!input.leaseId || activeLease.id !== input.leaseId) {
|
|
1982
|
+
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
1983
|
}
|
|
1704
|
-
assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
|
|
1705
1984
|
}
|
|
1706
1985
|
assertSafeToPersist(cleanJson(validations));
|
|
1707
|
-
|
|
1986
|
+
assertSafeToPersist(cleanJson(waivers));
|
|
1987
|
+
const evidenceFindings = [
|
|
1988
|
+
...(taskMode === 'read_only'
|
|
1989
|
+
? []
|
|
1990
|
+
: newResourceEvidenceFindings(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? [])),
|
|
1991
|
+
...validationEvidenceFindings(validations),
|
|
1992
|
+
];
|
|
1993
|
+
if (evidenceFindings.length > 0) {
|
|
1994
|
+
throw refuse(evidenceFindings.length === 1
|
|
1995
|
+
? evidenceFindings[0]
|
|
1996
|
+
: `Verification found ${evidenceFindings.length} unmet requirements: ${evidenceFindings
|
|
1997
|
+
.map((finding, index) => `(${index + 1}) ${finding}`)
|
|
1998
|
+
.join(' ')}`, 'task.verify', { findings: evidenceFindings });
|
|
1999
|
+
}
|
|
2000
|
+
const validationWaivers = [];
|
|
2001
|
+
if (waivers.length > 0) {
|
|
2002
|
+
const paths = renderedPaths(taskChangedPaths, resourceDiscoveryPolicy);
|
|
2003
|
+
const scope = await this.questionnaireScope(repository.repoRoot);
|
|
2004
|
+
for (const waiver of waivers) {
|
|
2005
|
+
const questionnaireId = validationWaiverId(input.taskId, waiver.validationId, paths, waiver.reason);
|
|
2006
|
+
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
2007
|
+
if (question?.answer?.choice !== 'waive') {
|
|
2008
|
+
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');
|
|
2009
|
+
}
|
|
2010
|
+
validationWaivers.push({
|
|
2011
|
+
validationId: waiver.validationId,
|
|
2012
|
+
questionnaireId,
|
|
2013
|
+
reason: waiver.reason,
|
|
2014
|
+
paths,
|
|
2015
|
+
answerSource: question.answerSource?.hostTool ?? 'mcp_form',
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
1708
2019
|
const pathChangeManifest = taskMode === 'read_only'
|
|
1709
2020
|
? []
|
|
1710
2021
|
: await buildPathChangeManifest(repository.repoRoot, taskChanges);
|
|
1711
2022
|
const verifyBody = cleanJson({
|
|
1712
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
2023
|
+
source: await this.checkoutSource(repository, pointer.source, pointer),
|
|
1713
2024
|
taskId: input.taskId,
|
|
1714
2025
|
sessionId: input.sessionId,
|
|
1715
2026
|
expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
|
|
@@ -1729,6 +2040,7 @@ export class BridgeService {
|
|
|
1729
2040
|
: {}),
|
|
1730
2041
|
...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
|
|
1731
2042
|
validations,
|
|
2043
|
+
...(validationWaivers.length > 0 ? { validationWaivers } : {}),
|
|
1732
2044
|
pendingOutboxCount,
|
|
1733
2045
|
newResources: input.newResources,
|
|
1734
2046
|
introducedStructures: input.introducedStructures,
|
|
@@ -1749,6 +2061,8 @@ export class BridgeService {
|
|
|
1749
2061
|
if (!isBackendUnavailableError(error)) {
|
|
1750
2062
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1751
2063
|
}
|
|
2064
|
+
if (backendRejectsField(error, ['validationWaivers']))
|
|
2065
|
+
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
2066
|
throw error;
|
|
1753
2067
|
}
|
|
1754
2068
|
const responseData = objectValue(response.data);
|
|
@@ -1772,6 +2086,7 @@ export class BridgeService {
|
|
|
1772
2086
|
else {
|
|
1773
2087
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1774
2088
|
}
|
|
2089
|
+
timer.finish();
|
|
1775
2090
|
return asJsonValue({
|
|
1776
2091
|
...objectOrEmpty(response.data),
|
|
1777
2092
|
mode: taskMode,
|
|
@@ -1783,11 +2098,45 @@ export class BridgeService {
|
|
|
1783
2098
|
});
|
|
1784
2099
|
});
|
|
1785
2100
|
}
|
|
2101
|
+
async validationWaiverSubject(input) {
|
|
2102
|
+
return await this.execute(async () => {
|
|
2103
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
2104
|
+
const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
|
|
2105
|
+
if (!pointer.changeBaseline) {
|
|
2106
|
+
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');
|
|
2107
|
+
}
|
|
2108
|
+
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
2109
|
+
method: 'POST',
|
|
2110
|
+
body: {
|
|
2111
|
+
sessionId: input.sessionId,
|
|
2112
|
+
repoFingerprint: repository.repoFingerprint,
|
|
2113
|
+
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2114
|
+
afterSequence: 0,
|
|
2115
|
+
},
|
|
2116
|
+
});
|
|
2117
|
+
const snapshot = objectValue(response.data);
|
|
2118
|
+
const activeLease = objectValue(snapshot?.activeLease);
|
|
2119
|
+
const paths = renderedPaths(taskChangeSet(pointer.changeBaseline, activeLease, repository.git.changedPaths).taskChanges.map((entry) => entry.path), readResourceDiscoveryPolicy(snapshot, activeLease));
|
|
2120
|
+
if (paths.length === 0) {
|
|
2121
|
+
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');
|
|
2122
|
+
}
|
|
2123
|
+
const reason = normalizePersistentInput(input.reason, repository.repoRoot);
|
|
2124
|
+
return asJsonValue({
|
|
2125
|
+
taskId: input.taskId,
|
|
2126
|
+
externalTaskId: pointer.taskSlug,
|
|
2127
|
+
validationId: input.validationId,
|
|
2128
|
+
paths,
|
|
2129
|
+
reason,
|
|
2130
|
+
questionnaireId: validationWaiverId(input.taskId, input.validationId, paths, reason),
|
|
2131
|
+
});
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
1786
2134
|
async taskClose(input) {
|
|
1787
2135
|
return await this.execute(async () => {
|
|
1788
2136
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1789
|
-
const
|
|
1790
|
-
const pointer = await this.requireActivePointer(
|
|
2137
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
2138
|
+
const pointer = await this.requireActivePointer(checkout.repoFingerprint, input.taskId, false, 'close');
|
|
2139
|
+
const repository = await this.taskRepository(checkout, pointer);
|
|
1791
2140
|
if (pointer.closeIntent) {
|
|
1792
2141
|
return await this.retryCloseIntent(repository, pointer);
|
|
1793
2142
|
}
|
|
@@ -1796,7 +2145,7 @@ export class BridgeService {
|
|
|
1796
2145
|
body: {
|
|
1797
2146
|
sessionId: pointer.sessionId,
|
|
1798
2147
|
repoFingerprint: repository.repoFingerprint,
|
|
1799
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2148
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1800
2149
|
afterSequence: 0,
|
|
1801
2150
|
},
|
|
1802
2151
|
});
|
|
@@ -1810,7 +2159,7 @@ export class BridgeService {
|
|
|
1810
2159
|
: [];
|
|
1811
2160
|
await this.assertTaskWorktree(repository, task, pointer);
|
|
1812
2161
|
const closeBody = {
|
|
1813
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2162
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1814
2163
|
taskId: input.taskId,
|
|
1815
2164
|
sessionId: pointer.sessionId,
|
|
1816
2165
|
expectedTaskVersion: numericTaskVersion(task.lockVersion),
|
|
@@ -1831,7 +2180,7 @@ export class BridgeService {
|
|
|
1831
2180
|
}
|
|
1832
2181
|
async worktreeTaskAllocation(input) {
|
|
1833
2182
|
return this.execute(async () => {
|
|
1834
|
-
const repository = await this.dependencies.repositories.
|
|
2183
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
1835
2184
|
const allocation = repository.projectId && this.dependencies.worktreePool
|
|
1836
2185
|
? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
|
|
1837
2186
|
: null;
|
|
@@ -1840,18 +2189,68 @@ export class BridgeService {
|
|
|
1840
2189
|
}
|
|
1841
2190
|
async projectGitPreferences(input) {
|
|
1842
2191
|
return this.execute(async () => {
|
|
1843
|
-
const repository = await this.dependencies.repositories.
|
|
2192
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd(), input.projectId);
|
|
1844
2193
|
if (!repository.projectId)
|
|
1845
2194
|
throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
|
|
1846
2195
|
const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
|
|
1847
2196
|
return asJsonValue({
|
|
1848
2197
|
...objectOrEmpty(objectValue(response.data)),
|
|
1849
2198
|
repoRoot: repository.repoRoot,
|
|
1850
|
-
currentBranch:
|
|
2199
|
+
currentBranch: repository.branch,
|
|
2200
|
+
currentCommit: repository.head,
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
async taskBranchPreflight(input) {
|
|
2205
|
+
return this.execute(async () => {
|
|
2206
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
2207
|
+
if (!repository.projectId)
|
|
2208
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
2209
|
+
const existingAllocation = this.dependencies.worktreePool
|
|
2210
|
+
? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
|
|
2211
|
+
: null;
|
|
2212
|
+
if (existingAllocation)
|
|
2213
|
+
return asJsonValue({ existingAllocation });
|
|
2214
|
+
if (input.name && !input.keepCurrent) {
|
|
2215
|
+
const unusable = await this.unusableBranchName(repository.repoRoot, input.name);
|
|
2216
|
+
if (unusable)
|
|
2217
|
+
throw refuse(unusable, 'task.branch');
|
|
2218
|
+
}
|
|
2219
|
+
const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
|
|
2220
|
+
this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
|
|
2221
|
+
this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
2222
|
+
this.folderState(repository, input.externalTaskId),
|
|
2223
|
+
this.suggestedBranchName(repository.repoRoot, input.externalTaskId),
|
|
2224
|
+
]);
|
|
2225
|
+
return asJsonValue({
|
|
2226
|
+
existingAllocation: null,
|
|
2227
|
+
preferences: preferences.data,
|
|
2228
|
+
currentBranch,
|
|
1851
2229
|
currentCommit: repository.git.head,
|
|
2230
|
+
folder,
|
|
2231
|
+
suggestedName,
|
|
1852
2232
|
});
|
|
1853
2233
|
});
|
|
1854
2234
|
}
|
|
2235
|
+
async suggestedBranchName(repoRoot, externalTaskId) {
|
|
2236
|
+
const candidates = suggestedBranchNames(externalTaskId);
|
|
2237
|
+
for (const candidate of candidates)
|
|
2238
|
+
if (!(await this.unusableBranchName(repoRoot, candidate)))
|
|
2239
|
+
return candidate;
|
|
2240
|
+
return candidates[0];
|
|
2241
|
+
}
|
|
2242
|
+
async unusableBranchName(repoRoot, name) {
|
|
2243
|
+
const git = this.dependencies.repositories.git;
|
|
2244
|
+
try {
|
|
2245
|
+
await git.validateBranch(repoRoot, name);
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
2248
|
+
return `${name} is not a valid Git branch name, so no task branch was created.`;
|
|
2249
|
+
}
|
|
2250
|
+
return (await git.branchExists(repoRoot, name))
|
|
2251
|
+
? `Branch ${name} already exists, so it cannot be created for a new task; nothing was created.`
|
|
2252
|
+
: null;
|
|
2253
|
+
}
|
|
1855
2254
|
async setProjectGitPreferences(projectId, body) {
|
|
1856
2255
|
return this.execute(async () => {
|
|
1857
2256
|
assertSafeToPersist(body);
|
|
@@ -1887,18 +2286,43 @@ export class BridgeService {
|
|
|
1887
2286
|
}
|
|
1888
2287
|
async worktreeOperation(operation, input) {
|
|
1889
2288
|
return this.execute(async () => {
|
|
1890
|
-
const
|
|
2289
|
+
const timer = startPhaseTimer(`worktree.${operation}`);
|
|
2290
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
2291
|
+
timer.mark('resolve_identity');
|
|
1891
2292
|
const projectId = repository.projectId;
|
|
1892
2293
|
const pool = this.dependencies.worktreePool;
|
|
1893
2294
|
if (!projectId || !pool)
|
|
1894
2295
|
throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
|
|
1895
2296
|
if (operation === 'list' || operation === 'reconcile') {
|
|
1896
2297
|
const managedRoot = await pool.managedRoot();
|
|
2298
|
+
const policy = await this.poolPolicy();
|
|
2299
|
+
timer.mark('policy');
|
|
2300
|
+
const reconciled = await pool.reconcile(projectId, repository.repoRoot, policy);
|
|
2301
|
+
timer.mark('reconcile');
|
|
2302
|
+
const items = reconciled.map((item) => ({
|
|
2303
|
+
...item,
|
|
2304
|
+
...(item.activity === 'retired'
|
|
2305
|
+
? {
|
|
2306
|
+
nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
|
|
2307
|
+
}
|
|
2308
|
+
: item.phase === 'delivery'
|
|
2309
|
+
? {
|
|
2310
|
+
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).',
|
|
2311
|
+
}
|
|
2312
|
+
: !item.managed && item.phase !== 'released'
|
|
2313
|
+
? {
|
|
2314
|
+
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.',
|
|
2315
|
+
}
|
|
2316
|
+
: {}),
|
|
2317
|
+
}));
|
|
2318
|
+
const retired = items.some((item) => item.activity === 'retired');
|
|
2319
|
+
const selected = await pool.forPath(projectId, repository.repoRoot);
|
|
2320
|
+
timer.finish();
|
|
1897
2321
|
return asJsonValue({
|
|
1898
|
-
items
|
|
1899
|
-
selected
|
|
2322
|
+
items,
|
|
2323
|
+
selected,
|
|
1900
2324
|
managedRoot,
|
|
1901
|
-
...(managedRoot.problem || managedRoot.synced
|
|
2325
|
+
...(managedRoot.problem || managedRoot.synced || retired
|
|
1902
2326
|
? {
|
|
1903
2327
|
notice: [
|
|
1904
2328
|
...(managedRoot.problem ? [managedRoot.problem] : []),
|
|
@@ -1907,6 +2331,11 @@ export class BridgeService {
|
|
|
1907
2331
|
`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
2332
|
]
|
|
1909
2333
|
: []),
|
|
2334
|
+
...(retired
|
|
2335
|
+
? [
|
|
2336
|
+
`Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
|
|
2337
|
+
]
|
|
2338
|
+
: []),
|
|
1910
2339
|
].join(' '),
|
|
1911
2340
|
}
|
|
1912
2341
|
: {}),
|
|
@@ -1934,29 +2363,96 @@ export class BridgeService {
|
|
|
1934
2363
|
await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
|
|
1935
2364
|
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1936
2365
|
}
|
|
2366
|
+
if (operation === 'retire') {
|
|
2367
|
+
const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
|
|
2368
|
+
if (!entry ||
|
|
2369
|
+
entry.generation !== input.generation ||
|
|
2370
|
+
input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
|
|
2371
|
+
throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
|
|
2372
|
+
const decision = await this.questionnaireResume({
|
|
2373
|
+
repoRoot: repository.repoRoot,
|
|
2374
|
+
questionnaireId: input.decisionId,
|
|
2375
|
+
});
|
|
2376
|
+
if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
|
|
2377
|
+
throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
|
|
2378
|
+
await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
|
|
2379
|
+
return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
|
|
2380
|
+
}
|
|
1937
2381
|
const entry = await pool.forPath(projectId, repository.repoRoot);
|
|
1938
2382
|
if (!entry ||
|
|
1939
2383
|
entry.externalTaskId !== input.externalTaskId ||
|
|
1940
2384
|
entry.generation !== input.generation)
|
|
1941
2385
|
throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
|
|
2386
|
+
if (operation === 'pause' && input.delivered !== undefined)
|
|
2387
|
+
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');
|
|
2388
|
+
const outcome = input.deliveryOutcome ?? (input.delivered === true ? 'delivered' : undefined);
|
|
2389
|
+
if (operation === 'release' && outcome === 'cancelled')
|
|
2390
|
+
throw refuse('A cancelled delivery is settled through the native question that worktree.release with deliveryOutcome cancelled asks.', 'worktree.release');
|
|
1942
2391
|
const perform = async () => {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2392
|
+
try {
|
|
2393
|
+
if (operation === 'heartbeat') {
|
|
2394
|
+
await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
|
|
2395
|
+
}
|
|
2396
|
+
if (operation === 'pause') {
|
|
2397
|
+
await pool.pause(projectId, repository.repoRoot, input.generation);
|
|
2398
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2399
|
+
}
|
|
2400
|
+
if (operation === 'release') {
|
|
2401
|
+
await this.requireDeliveredTaskWork(entry);
|
|
2402
|
+
await pool.release(projectId, repository.repoRoot, input.generation, outcome === 'delivered' ? outcome : undefined);
|
|
2403
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2404
|
+
}
|
|
2405
|
+
if (operation === 'cancel_delivery') {
|
|
2406
|
+
if (!entry.pendingDelivery)
|
|
2407
|
+
throw refuse('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
|
|
2408
|
+
const manifest = await this.dependencies.repositories.git.manifest(repository.repoRoot);
|
|
2409
|
+
const clean = manifest.changedPaths.length === 0;
|
|
2410
|
+
if (input.decisionId !== cancelledDeliveryQuestionId(entry, clean))
|
|
2411
|
+
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');
|
|
2412
|
+
const decision = await this.questionnaireResume({
|
|
2413
|
+
repoRoot: repository.repoRoot,
|
|
2414
|
+
questionnaireId: input.decisionId,
|
|
2415
|
+
});
|
|
2416
|
+
const choice = decision.answer?.choice ?? '';
|
|
2417
|
+
const choices = clean || !entry.managed ? ['release', 'keep'] : ['save_and_release', 'keep'];
|
|
2418
|
+
if (decision.status !== 'answered' || !choices.includes(choice))
|
|
2419
|
+
throw refuse('Ask the user what happens to the folder in the native worktree.release form.', 'worktree.release');
|
|
2420
|
+
if (choice !== 'keep')
|
|
2421
|
+
await this.requireDeliveredTaskWork(entry);
|
|
2422
|
+
const cancelled = await pool.cancelDelivery(projectId, repository.repoRoot, input.generation, choice);
|
|
2423
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2424
|
+
return asJsonValue({
|
|
2425
|
+
operation,
|
|
2426
|
+
complete: true,
|
|
2427
|
+
repoRoot: repository.repoRoot,
|
|
2428
|
+
deliveryOutcome: 'cancelled',
|
|
2429
|
+
...cancelled,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1948
2433
|
}
|
|
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);
|
|
2434
|
+
finally {
|
|
2435
|
+
timer.finish();
|
|
1954
2436
|
}
|
|
1955
|
-
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1956
2437
|
};
|
|
1957
2438
|
return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
|
|
1958
2439
|
});
|
|
1959
2440
|
}
|
|
2441
|
+
async requireDeliveredTaskWork(entry) {
|
|
2442
|
+
if (!entry.taskId)
|
|
2443
|
+
return;
|
|
2444
|
+
await this.flushOutbox(entry.taskId);
|
|
2445
|
+
if (!entry.managed)
|
|
2446
|
+
return;
|
|
2447
|
+
const queued = await this.dependencies.outbox.listForTask(entry.taskId);
|
|
2448
|
+
if (queued.length)
|
|
2449
|
+
throw refuse(`Task ${entry.externalTaskId} still has ${queued.length} record(s) in the local outbox that the backend has not accepted: ${queued
|
|
2450
|
+
.map((item) => `${item.id} (${item.operation}${item.lastError ? ', ' + item.lastError : ''})`)
|
|
2451
|
+
.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');
|
|
2452
|
+
const pointer = await this.dependencies.activeContexts.loadForTask(entry.repoFingerprint, entry.taskId);
|
|
2453
|
+
if (pointer?.verificationIntent || pointer?.closeIntent)
|
|
2454
|
+
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');
|
|
2455
|
+
}
|
|
1960
2456
|
async pooledTaskBranch(repository, input) {
|
|
1961
2457
|
const pool = this.dependencies.worktreePool;
|
|
1962
2458
|
const projectId = repository.projectId;
|
|
@@ -1967,24 +2463,72 @@ export class BridgeService {
|
|
|
1967
2463
|
if (existing)
|
|
1968
2464
|
source = { sourceCommit: existing.baseCommit };
|
|
1969
2465
|
else {
|
|
1970
|
-
if (!input.decisionId || !input.base)
|
|
2466
|
+
if (!input.decisionId || !input.startId || !input.base)
|
|
1971
2467
|
throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
|
|
1972
|
-
const
|
|
2468
|
+
const start = taskStartChoice(await this.questionnaireResume({
|
|
1973
2469
|
repoRoot: repository.repoRoot,
|
|
1974
|
-
questionnaireId: input.
|
|
1975
|
-
});
|
|
1976
|
-
const
|
|
2470
|
+
questionnaireId: input.startId,
|
|
2471
|
+
}));
|
|
2472
|
+
const request = {
|
|
1977
2473
|
externalTaskId: input.externalTaskId,
|
|
1978
2474
|
base: input.base,
|
|
1979
2475
|
name: input.name ?? null,
|
|
1980
2476
|
keepCurrent: input.keepCurrent === true,
|
|
1981
2477
|
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2478
|
+
};
|
|
2479
|
+
const started = start?.status === 'start' &&
|
|
2480
|
+
start.externalTaskId === request.externalTaskId &&
|
|
2481
|
+
start.decisionAttempt === input.decisionAttempt &&
|
|
2482
|
+
start.name === request.name &&
|
|
2483
|
+
start.keepCurrent === request.keepCurrent &&
|
|
2484
|
+
start.inPlace === (input.inPlace === true)
|
|
2485
|
+
? start
|
|
2486
|
+
: null;
|
|
2487
|
+
let approved = false;
|
|
2488
|
+
if (started && input.decisionId === input.startId)
|
|
2489
|
+
approved = stableStringify(started.base) === stableStringify(input.base);
|
|
2490
|
+
else if (started &&
|
|
2491
|
+
input.decisionId ===
|
|
2492
|
+
'task-branch-fallback-' +
|
|
2493
|
+
sha256(stableStringify({ ...request, inPlace: input.inPlace === true }))) {
|
|
2494
|
+
const fallback = await this.questionnaireResume({
|
|
2495
|
+
repoRoot: repository.repoRoot,
|
|
2496
|
+
questionnaireId: input.decisionId,
|
|
2497
|
+
});
|
|
2498
|
+
approved = fallback.status === 'answered' && fallback.answer?.choice === 'approve';
|
|
2499
|
+
}
|
|
2500
|
+
if (!approved)
|
|
2501
|
+
throw refuse('The task branch decision does not match the approved source, branch and location.', 'task.branch');
|
|
2502
|
+
const holder = input.inPlace
|
|
2503
|
+
? await this.folderHolder(repository, input.externalTaskId)
|
|
2504
|
+
: null;
|
|
1986
2505
|
source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
|
|
2506
|
+
if (holder && input.inPlace) {
|
|
2507
|
+
if (holder.managed || holder.running || !sameHolder(holder, started.holder))
|
|
2508
|
+
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');
|
|
2509
|
+
if (repository.git.changedPaths.length > 0)
|
|
2510
|
+
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');
|
|
2511
|
+
if (await git.branchExists(repository.repoRoot, input.name))
|
|
2512
|
+
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');
|
|
2513
|
+
if (holder.generation) {
|
|
2514
|
+
await pool.moveOut(projectId, repository.repoRoot, holder.generation, holder.oid ?? undefined);
|
|
2515
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2516
|
+
}
|
|
2517
|
+
else
|
|
2518
|
+
await git.branchStore(repository.repoRoot).detach({
|
|
2519
|
+
projectId: holder.projectId,
|
|
2520
|
+
externalTaskId: holder.externalTaskId,
|
|
2521
|
+
branch: holder.branch,
|
|
2522
|
+
...(holder.taskId ? { taskId: holder.taskId } : {}),
|
|
2523
|
+
}, holder.oid);
|
|
2524
|
+
const parked = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, holder.externalTaskId);
|
|
2525
|
+
if (parked?.worktreeId === sha256(repository.repoRoot))
|
|
2526
|
+
await this.parkPointer(parked);
|
|
2527
|
+
}
|
|
1987
2528
|
}
|
|
2529
|
+
const allocationTimer = startPhaseTimer('task.branch.allocate');
|
|
2530
|
+
const allocationPolicy = await this.poolPolicy(true);
|
|
2531
|
+
allocationTimer.mark('policy');
|
|
1988
2532
|
const allocation = await pool.allocate({
|
|
1989
2533
|
projectId,
|
|
1990
2534
|
projectName: preferences.projectName ?? projectId,
|
|
@@ -1994,8 +2538,11 @@ export class BridgeService {
|
|
|
1994
2538
|
branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
|
|
1995
2539
|
baseCommit: source.sourceCommit,
|
|
1996
2540
|
keepCurrent: input.keepCurrent,
|
|
2541
|
+
inPlace: !existing && input.inPlace,
|
|
1997
2542
|
resume: Boolean(existing),
|
|
1998
|
-
},
|
|
2543
|
+
}, allocationPolicy);
|
|
2544
|
+
allocationTimer.mark('pool_allocate');
|
|
2545
|
+
allocationTimer.finish();
|
|
1999
2546
|
this.poolAllocations.set(allocation.repoRoot, allocation);
|
|
2000
2547
|
if (existing && allocation.repoRoot === repository.repoRoot) {
|
|
2001
2548
|
const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
|
|
@@ -2067,8 +2614,9 @@ export class BridgeService {
|
|
|
2067
2614
|
(await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
|
|
2068
2615
|
return this.pooledTaskBranch(repository, input);
|
|
2069
2616
|
if (Boolean(input.name) === (input.keepCurrent === true) ||
|
|
2070
|
-
(input.worktreePath && !input.name)
|
|
2071
|
-
|
|
2617
|
+
(input.worktreePath && !input.name) ||
|
|
2618
|
+
(input.inPlace && input.worktreePath))
|
|
2619
|
+
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
2620
|
assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
|
|
2073
2621
|
if (this.dependencies.worktreePool)
|
|
2074
2622
|
return this.pooledTaskBranch(repository, input);
|
|
@@ -2124,7 +2672,24 @@ export class BridgeService {
|
|
|
2124
2672
|
async requireBranchDecision(repository, externalTaskId) {
|
|
2125
2673
|
const git = this.dependencies.repositories.git;
|
|
2126
2674
|
const store = git.branchStore(repository.repoRoot);
|
|
2675
|
+
const holder = await this.folderHolder(repository, externalTaskId);
|
|
2676
|
+
if (holder) {
|
|
2677
|
+
const held = `This folder is held by task ${holder.externalTaskId} (branch ${holder.branch ?? 'detached HEAD'}, last activity ${holder.lastCheckpointAt ?? 'unknown'})`;
|
|
2678
|
+
throw refuse(holder.managed
|
|
2679
|
+
? `${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.`
|
|
2680
|
+
: holder.running
|
|
2681
|
+
? `${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.`
|
|
2682
|
+
: holder.pendingDelivery
|
|
2683
|
+
? `${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.`
|
|
2684
|
+
: !holder.checkoutClean
|
|
2685
|
+
? `${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.`
|
|
2686
|
+
: `${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');
|
|
2687
|
+
}
|
|
2127
2688
|
let record = await store.read();
|
|
2689
|
+
if (record &&
|
|
2690
|
+
(record.decision.projectId !== repository.projectId ||
|
|
2691
|
+
record.decision.externalTaskId !== externalTaskId))
|
|
2692
|
+
record = null;
|
|
2128
2693
|
if (!record) {
|
|
2129
2694
|
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
|
|
2130
2695
|
if (pointer?.worktreeId === sha256(repository.repoRoot) &&
|
|
@@ -2140,15 +2705,102 @@ export class BridgeService {
|
|
|
2140
2705
|
}
|
|
2141
2706
|
}
|
|
2142
2707
|
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');
|
|
2708
|
+
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
2709
|
const branch = await git.currentBranch(repository.repoRoot);
|
|
2148
2710
|
if (branch !== record.decision.branch)
|
|
2149
2711
|
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
2712
|
return record.decision;
|
|
2151
2713
|
}
|
|
2714
|
+
async folderHolder(repository, externalTaskId) {
|
|
2715
|
+
const pool = this.dependencies.worktreePool;
|
|
2716
|
+
const store = this.dependencies.repositories.git.branchStore(repository.repoRoot);
|
|
2717
|
+
const record = await store.read();
|
|
2718
|
+
const found = pool && repository.projectId
|
|
2719
|
+
? await pool.forPath(repository.projectId, repository.repoRoot)
|
|
2720
|
+
: null;
|
|
2721
|
+
const entry = found && found.phase !== 'released' && found.externalTaskId !== externalTaskId ? found : null;
|
|
2722
|
+
const decision = record &&
|
|
2723
|
+
(record.decision.projectId !== repository.projectId ||
|
|
2724
|
+
record.decision.externalTaskId !== externalTaskId)
|
|
2725
|
+
? record.decision
|
|
2726
|
+
: null;
|
|
2727
|
+
if (!entry && !decision)
|
|
2728
|
+
return null;
|
|
2729
|
+
const slug = entry?.externalTaskId ?? decision.externalTaskId;
|
|
2730
|
+
const taskId = entry ? entry.taskId : decision.taskId;
|
|
2731
|
+
const running = entry ? await pool.ownedElsewhere(entry) : false;
|
|
2732
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, slug);
|
|
2733
|
+
let status = null;
|
|
2734
|
+
if (taskId && !entry?.managed) {
|
|
2735
|
+
try {
|
|
2736
|
+
status = objectValue((await this.dependencies.client.request(endpoints.taskStatus(taskId))).data);
|
|
2737
|
+
}
|
|
2738
|
+
catch {
|
|
2739
|
+
status = null;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
const pendingDelivery = entry?.pendingDelivery === true;
|
|
2743
|
+
if (taskId &&
|
|
2744
|
+
!entry?.managed &&
|
|
2745
|
+
!running &&
|
|
2746
|
+
!pendingDelivery &&
|
|
2747
|
+
(status?.status === 'closed' || status?.status === 'abandoned')) {
|
|
2748
|
+
if (entry) {
|
|
2749
|
+
await pool.moveOut(entry.projectId, repository.repoRoot, entry.generation);
|
|
2750
|
+
this.poolAllocations.delete(repository.repoRoot);
|
|
2751
|
+
}
|
|
2752
|
+
else
|
|
2753
|
+
await store.release(taskId);
|
|
2754
|
+
if (pointer?.worktreeId === sha256(repository.repoRoot)) {
|
|
2755
|
+
if (status.status === 'abandoned')
|
|
2756
|
+
await this.dependencies.activeContexts.forget(repository.repoFingerprint, pointer.taskId);
|
|
2757
|
+
else
|
|
2758
|
+
await this.parkPointer({
|
|
2759
|
+
...pointer,
|
|
2760
|
+
closedAt: pointer.closedAt ?? new Date().toISOString(),
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
return null;
|
|
2764
|
+
}
|
|
2765
|
+
return {
|
|
2766
|
+
projectId: entry?.projectId ?? decision.projectId,
|
|
2767
|
+
externalTaskId: slug,
|
|
2768
|
+
taskId: taskId ?? null,
|
|
2769
|
+
branch: entry ? entry.branch : decision.branch,
|
|
2770
|
+
lastCheckpointAt: typeof status?.updatedAt === 'string' ? status.updatedAt : (pointer?.updatedAt ?? null),
|
|
2771
|
+
checkoutClean: repository.git.changedPaths.length === 0,
|
|
2772
|
+
oid: record && decision?.externalTaskId === slug ? record.oid : null,
|
|
2773
|
+
generation: entry && !entry.managed ? entry.generation : null,
|
|
2774
|
+
managed: entry?.managed === true,
|
|
2775
|
+
running,
|
|
2776
|
+
pendingDelivery,
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
async parkPointer(pointer) {
|
|
2780
|
+
const { worktreeId: _worktreeId, worktreeGeneration: _generation, ...parked } = pointer;
|
|
2781
|
+
await this.dependencies.activeContexts.save(parked);
|
|
2782
|
+
}
|
|
2783
|
+
async worktreeFolder(input) {
|
|
2784
|
+
return this.execute(async () => {
|
|
2785
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
2786
|
+
if (!repository.projectId)
|
|
2787
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
2788
|
+
return asJsonValue(await this.folderState(repository, input.externalTaskId));
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
async folderState(repository, externalTaskId) {
|
|
2792
|
+
const holder = await this.folderHolder(repository, externalTaskId);
|
|
2793
|
+
const entry = this.dependencies.worktreePool
|
|
2794
|
+
? await this.dependencies.worktreePool.forPath(repository.projectId, repository.repoRoot)
|
|
2795
|
+
: null;
|
|
2796
|
+
return {
|
|
2797
|
+
managed: entry?.managed === true,
|
|
2798
|
+
heldBy: holder && (holder.managed || holder.running) ? holder.externalTaskId : null,
|
|
2799
|
+
clean: repository.git.changedPaths.length === 0,
|
|
2800
|
+
deliveryPending: entry?.externalTaskId === externalTaskId && entry.pendingDelivery === true,
|
|
2801
|
+
holder: holder && !holder.managed && !holder.running ? holder : null,
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2152
2804
|
async assertTaskWorktree(repository, task, pointer) {
|
|
2153
2805
|
if (normalizeTaskMode(task.mode) === 'read_only')
|
|
2154
2806
|
return;
|
|
@@ -2177,7 +2829,10 @@ export class BridgeService {
|
|
|
2177
2829
|
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
2178
2830
|
: this.poolAllocations.get(repository.repoRoot);
|
|
2179
2831
|
if (allocation && this.dependencies.worktreePool) {
|
|
2180
|
-
|
|
2832
|
+
if (allocation.managed)
|
|
2833
|
+
await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
|
|
2834
|
+
else
|
|
2835
|
+
await this.dependencies.worktreePool.release(allocation.projectId, allocation.repoRoot, allocation.generation);
|
|
2181
2836
|
this.poolAllocations.delete(repository.repoRoot);
|
|
2182
2837
|
}
|
|
2183
2838
|
else
|
|
@@ -2611,10 +3266,13 @@ export class BridgeService {
|
|
|
2611
3266
|
}
|
|
2612
3267
|
async sessionEntry(input = {}) {
|
|
2613
3268
|
return await this.execute(async () => {
|
|
3269
|
+
const timer = startPhaseTimer('session.entry');
|
|
2614
3270
|
const authentication = await this.dependencies.browserAuth.status();
|
|
3271
|
+
timer.mark('auth_status');
|
|
2615
3272
|
let repository;
|
|
2616
3273
|
try {
|
|
2617
3274
|
repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
3275
|
+
timer.mark('resolve_repository');
|
|
2618
3276
|
}
|
|
2619
3277
|
catch (error) {
|
|
2620
3278
|
const missingTarget = error.code === 'ENOENT';
|
|
@@ -2636,32 +3294,44 @@ export class BridgeService {
|
|
|
2636
3294
|
});
|
|
2637
3295
|
}
|
|
2638
3296
|
const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
|
|
3297
|
+
timer.mark('load_decision');
|
|
2639
3298
|
const authenticated = authentication.authenticated === true;
|
|
2640
3299
|
const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
|
|
2641
|
-
const
|
|
3300
|
+
const projectId = repository.projectId;
|
|
3301
|
+
const livePointers = authenticated && state === 'bound'
|
|
3302
|
+
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
3303
|
+
: Promise.resolve([]);
|
|
3304
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires] = await Promise.all([
|
|
3305
|
+
this.clientUpdate(authenticated),
|
|
3306
|
+
livePointers,
|
|
3307
|
+
authenticated && state === 'bound' && projectId
|
|
3308
|
+
? this.actionableWorkItems(projectId)
|
|
3309
|
+
: Promise.resolve([]),
|
|
3310
|
+
authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
3311
|
+
? this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
|
|
3312
|
+
: Promise.resolve(null),
|
|
3313
|
+
authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
3314
|
+
? this.worktreeOperation('list', { repoRoot: repository.repoRoot })
|
|
3315
|
+
: Promise.resolve(null),
|
|
3316
|
+
state !== 'disabled'
|
|
3317
|
+
? Promise.all([
|
|
3318
|
+
livePointers,
|
|
3319
|
+
this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
|
|
3320
|
+
]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
|
|
3321
|
+
: Promise.resolve({ mine: [], other: [], deferred: [] }),
|
|
3322
|
+
]);
|
|
3323
|
+
timer.mark('independent_lookups');
|
|
3324
|
+
const liveTasks = liveTasksRaw.map(describePointer);
|
|
2642
3325
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
2643
3326
|
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
3327
|
const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
|
|
2655
3328
|
const unansweredBranches = preferences?.canManage === true
|
|
2656
3329
|
? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
|
|
2657
3330
|
.filter(([, value]) => objectValue(value)?.answered === false)
|
|
2658
3331
|
.map(([role]) => role)
|
|
2659
3332
|
: [];
|
|
2660
|
-
const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
2661
|
-
? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
|
|
2662
|
-
: null;
|
|
2663
3333
|
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
2664
|
-
|
|
3334
|
+
timer.finish();
|
|
2665
3335
|
return asJsonValue({
|
|
2666
3336
|
authenticated,
|
|
2667
3337
|
repository: publicRepository(repository),
|
|
@@ -2676,21 +3346,24 @@ export class BridgeService {
|
|
|
2676
3346
|
gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
|
|
2677
3347
|
unansweredBranchPreferences: unansweredBranches,
|
|
2678
3348
|
branchPreferenceAction: unansweredBranches.length
|
|
2679
|
-
? 'Ask
|
|
3349
|
+
? '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
3350
|
: 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
|
|
2681
3351
|
}
|
|
2682
3352
|
: {}),
|
|
2683
|
-
...(state !== 'disabled' ?
|
|
3353
|
+
...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
|
|
3354
|
+
...(pendingQuestionnaires.deferred.length > 0
|
|
3355
|
+
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
3356
|
+
: {}),
|
|
2684
3357
|
client,
|
|
2685
3358
|
...(incomplete && state === 'bound'
|
|
2686
3359
|
? {
|
|
2687
3360
|
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
3361
|
}
|
|
2689
3362
|
: {}),
|
|
2690
|
-
nextAction: pendingQuestionnaires.length > 0
|
|
3363
|
+
nextAction: pendingQuestionnaires.mine.length > 0
|
|
2691
3364
|
? '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.'
|
|
2692
3365
|
: taskSelectionRequired
|
|
2693
|
-
? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or
|
|
3366
|
+
? '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.'
|
|
2694
3367
|
: entryNextAction(authenticated, state),
|
|
2695
3368
|
});
|
|
2696
3369
|
});
|
|
@@ -2749,7 +3422,7 @@ export class BridgeService {
|
|
|
2749
3422
|
}
|
|
2750
3423
|
async sessionSetDecision(input) {
|
|
2751
3424
|
return await this.execute(async () => {
|
|
2752
|
-
const repository = await this.dependencies.repositories.
|
|
3425
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
2753
3426
|
if (input.decision === 'none') {
|
|
2754
3427
|
await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
|
|
2755
3428
|
return asJsonValue({
|
|
@@ -2892,6 +3565,10 @@ export class BridgeService {
|
|
|
2892
3565
|
}
|
|
2893
3566
|
const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
|
|
2894
3567
|
const pointer = await this.resolveCheckpointPointer(input, repoRoot);
|
|
3568
|
+
if (pointer.closedAt && correction)
|
|
3569
|
+
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');
|
|
3570
|
+
if (pointer.closedAt)
|
|
3571
|
+
throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. Further work belongs to a new task.`, 'session.bootstrap');
|
|
2895
3572
|
const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
|
|
2896
3573
|
if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
|
|
2897
3574
|
throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
|
|
@@ -3013,7 +3690,7 @@ export class BridgeService {
|
|
|
3013
3690
|
throw refuse('The receipt does not belong to this task project.', 'session.resume');
|
|
3014
3691
|
if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
|
|
3015
3692
|
throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
|
|
3016
|
-
await this.checkoutSource(repository, pointer.source);
|
|
3693
|
+
await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
|
|
3017
3694
|
await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
|
|
3018
3695
|
const entries = await this.dependencies.outbox.listForTask(taskId);
|
|
3019
3696
|
if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
|
|
@@ -3127,19 +3804,16 @@ export class BridgeService {
|
|
|
3127
3804
|
});
|
|
3128
3805
|
const backend = objectValue(response.data);
|
|
3129
3806
|
const task = objectValue(backend?.task);
|
|
3130
|
-
if (!task ||
|
|
3131
|
-
task.id !== body.taskId ||
|
|
3132
|
-
task.projectId !== entry.projectId ||
|
|
3133
|
-
backend?.requiresContextRefresh === true)
|
|
3807
|
+
if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
|
|
3134
3808
|
throw refuse('Refresh the matching task context before delivering this review.', 'session.resume');
|
|
3809
|
+
if (backend?.requiresContextRefresh === true)
|
|
3810
|
+
throw refuse('Approved memory changed what this task read, so its context must be refreshed before this review is delivered.', 'context.refresh');
|
|
3135
3811
|
recovered = Boolean(task &&
|
|
3136
3812
|
task.id === body.taskId &&
|
|
3137
3813
|
task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
|
|
3138
3814
|
task.selfReviewDiffHash === body.diffHash &&
|
|
3139
|
-
stableStringify(asJsonValue(task.
|
|
3140
|
-
stableStringify(asJsonValue(body.
|
|
3141
|
-
stableStringify(asJsonValue(task.selfReviewFindings)) ===
|
|
3142
|
-
stableStringify(asJsonValue(body.findings)));
|
|
3815
|
+
stableStringify(asJsonValue(task.selfReviewFiles)) ===
|
|
3816
|
+
stableStringify(asJsonValue(body.files)));
|
|
3143
3817
|
if (recovered)
|
|
3144
3818
|
await this.captureTaskSnapshot(response.data);
|
|
3145
3819
|
}
|
|
@@ -3182,13 +3856,34 @@ export class BridgeService {
|
|
|
3182
3856
|
}
|
|
3183
3857
|
return pending.length;
|
|
3184
3858
|
}
|
|
3185
|
-
async
|
|
3859
|
+
async taskRepository(repository, pointer) {
|
|
3860
|
+
const base = pointer?.source?.sourceCommit;
|
|
3861
|
+
const git = this.dependencies.repositories.git;
|
|
3862
|
+
if (!pointer ||
|
|
3863
|
+
!base ||
|
|
3864
|
+
!repository.git.head ||
|
|
3865
|
+
repository.git.head === base ||
|
|
3866
|
+
normalizeTaskMode(pointer.mode) === 'read_only' ||
|
|
3867
|
+
!pointer.changeBaseline ||
|
|
3868
|
+
typeof pointer.branch !== 'string' ||
|
|
3869
|
+
(await git.currentBranch(repository.repoRoot)) !== pointer.branch ||
|
|
3870
|
+
!(await git.isAncestor(repository.repoRoot, base)) ||
|
|
3871
|
+
!pathsContainAll(pointer.changeBaseline.leasePaths, await git.committedPaths(repository.repoRoot, base)))
|
|
3872
|
+
return repository;
|
|
3873
|
+
return { ...repository, git: await git.manifestAgainst(repository.repoRoot, base) };
|
|
3874
|
+
}
|
|
3875
|
+
async checkoutSource(repository, expected, task) {
|
|
3186
3876
|
const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
|
|
3187
3877
|
if (expected &&
|
|
3188
3878
|
(!current ||
|
|
3189
3879
|
current.sourceCommit !== expected.sourceCommit ||
|
|
3190
3880
|
current.treeHash !== expected.treeHash)) {
|
|
3191
|
-
|
|
3881
|
+
if (current &&
|
|
3882
|
+
repository.git.baseCommit === expected.sourceCommit &&
|
|
3883
|
+
repository.git.head === current.sourceCommit)
|
|
3884
|
+
return { ...expected };
|
|
3885
|
+
const branch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
3886
|
+
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
3887
|
}
|
|
3193
3888
|
if (current && repository.git.head !== current.sourceCommit) {
|
|
3194
3889
|
throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
|
|
@@ -3214,14 +3909,17 @@ export class BridgeService {
|
|
|
3214
3909
|
body: {
|
|
3215
3910
|
sessionId: pointer.sessionId,
|
|
3216
3911
|
repoFingerprint: repository.repoFingerprint,
|
|
3217
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
3912
|
+
source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
|
|
3218
3913
|
afterSequence: 0,
|
|
3219
3914
|
},
|
|
3220
3915
|
});
|
|
3221
3916
|
const backend = objectValue(response.data);
|
|
3222
3917
|
const task = objectValue(backend?.task);
|
|
3223
|
-
if (!task || task.id !== taskId
|
|
3224
|
-
throw refuse('Backend task snapshot
|
|
3918
|
+
if (!task || task.id !== taskId) {
|
|
3919
|
+
throw refuse('Backend task snapshot does not match the active task', 'session.resume');
|
|
3920
|
+
}
|
|
3921
|
+
if (backend?.requiresContextRefresh === true) {
|
|
3922
|
+
throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
3225
3923
|
}
|
|
3226
3924
|
const refreshed = {
|
|
3227
3925
|
...pointer,
|
|
@@ -3243,8 +3941,11 @@ export class BridgeService {
|
|
|
3243
3941
|
});
|
|
3244
3942
|
const backend = objectValue(response.data);
|
|
3245
3943
|
const task = objectValue(backend?.task);
|
|
3246
|
-
if (!task || task.id !== taskId
|
|
3247
|
-
throw refuse('Backend task snapshot
|
|
3944
|
+
if (!task || task.id !== taskId) {
|
|
3945
|
+
throw refuse('Backend task snapshot does not match the pending delivery', 'session.resume');
|
|
3946
|
+
}
|
|
3947
|
+
if (backend?.requiresContextRefresh === true) {
|
|
3948
|
+
throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
|
|
3248
3949
|
}
|
|
3249
3950
|
const refreshed = {
|
|
3250
3951
|
...pointer,
|
|
@@ -3259,6 +3960,13 @@ export class BridgeService {
|
|
|
3259
3960
|
const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
|
|
3260
3961
|
const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
|
|
3261
3962
|
(pointer?.closeIntent && allowedIntent !== 'close');
|
|
3963
|
+
if (pointer &&
|
|
3964
|
+
pointer.taskId === taskId &&
|
|
3965
|
+
!lifecycleIntentBlocked &&
|
|
3966
|
+
blockingConflicts.length > 0 &&
|
|
3967
|
+
blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
|
|
3968
|
+
throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
|
|
3969
|
+
}
|
|
3262
3970
|
if (!pointer ||
|
|
3263
3971
|
pointer.taskId !== taskId ||
|
|
3264
3972
|
(blockingConflicts?.length ?? 0) > 0 ||
|
|
@@ -3275,7 +3983,7 @@ export class BridgeService {
|
|
|
3275
3983
|
}
|
|
3276
3984
|
}
|
|
3277
3985
|
async retryVerificationRecovery(repository, pointer, backend) {
|
|
3278
|
-
await this.checkoutSource(repository, pointer.source);
|
|
3986
|
+
await this.checkoutSource(repository, pointer.source, pointer);
|
|
3279
3987
|
const intent = pointer.verificationIntent;
|
|
3280
3988
|
const recovery = objectValue(backend?.verificationRecovery);
|
|
3281
3989
|
if (!intent) {
|
|
@@ -3402,7 +4110,9 @@ export class BridgeService {
|
|
|
3402
4110
|
return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
|
|
3403
4111
|
}
|
|
3404
4112
|
async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
|
|
3405
|
-
await this.checkoutSource(repository, sourceFromContext(body.source)
|
|
4113
|
+
await this.checkoutSource(repository, sourceFromContext(body.source), {
|
|
4114
|
+
branch: typeof body.branch === 'string' ? body.branch : null,
|
|
4115
|
+
});
|
|
3406
4116
|
const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
|
|
3407
4117
|
const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
3408
4118
|
if (typeof body.branch === 'string' && body.branch !== currentBranch)
|
|
@@ -3443,6 +4153,12 @@ export class BridgeService {
|
|
|
3443
4153
|
taskChanges,
|
|
3444
4154
|
});
|
|
3445
4155
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
4156
|
+
const closedPointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, String(body.taskId));
|
|
4157
|
+
if (closedPointer)
|
|
4158
|
+
await this.dependencies.activeContexts.save({
|
|
4159
|
+
...closedPointer,
|
|
4160
|
+
closedAt: new Date().toISOString(),
|
|
4161
|
+
});
|
|
3446
4162
|
const allocation = repository.projectId
|
|
3447
4163
|
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
3448
4164
|
: this.poolAllocations.get(repository.repoRoot);
|
|
@@ -3456,7 +4172,7 @@ export class BridgeService {
|
|
|
3456
4172
|
...closedTask,
|
|
3457
4173
|
repository: publicRepository(repository),
|
|
3458
4174
|
recoveredAfterResponseLoss,
|
|
3459
|
-
delivery: deliveryQuestion(touchedContract),
|
|
4175
|
+
delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
|
|
3460
4176
|
});
|
|
3461
4177
|
}
|
|
3462
4178
|
async seedResumeSnapshot(pointer, backendPatch) {
|
|
@@ -3506,7 +4222,14 @@ export class BridgeService {
|
|
|
3506
4222
|
return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
|
|
3507
4223
|
})()
|
|
3508
4224
|
: await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
|
|
3509
|
-
|
|
4225
|
+
const blockingConflicts = pointer.resumeConflicts.filter((conflict) => !isOfflineDevelopmentWarning(conflict));
|
|
4226
|
+
if (!pointer.verificationIntent &&
|
|
4227
|
+
!pointer.closeIntent &&
|
|
4228
|
+
blockingConflicts.length > 0 &&
|
|
4229
|
+
blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
|
|
4230
|
+
throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
|
|
4231
|
+
}
|
|
4232
|
+
if (blockingConflicts.length > 0) {
|
|
3510
4233
|
throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
|
|
3511
4234
|
}
|
|
3512
4235
|
if (pointer.verificationIntent || pointer.closeIntent) {
|
|
@@ -3587,6 +4310,7 @@ export class BridgeService {
|
|
|
3587
4310
|
code: error.code ?? error.httpStatus,
|
|
3588
4311
|
retryable: error.retryable,
|
|
3589
4312
|
...(error.recovery ? { recovery: error.recovery } : {}),
|
|
4313
|
+
...(error.envelope?.data ? { details: error.envelope.data } : {}),
|
|
3590
4314
|
},
|
|
3591
4315
|
};
|
|
3592
4316
|
}
|
|
@@ -3597,6 +4321,9 @@ export class BridgeService {
|
|
|
3597
4321
|
message: error instanceof Error ? error.message : 'Bridge operation failed',
|
|
3598
4322
|
retryable: false,
|
|
3599
4323
|
...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
|
|
4324
|
+
...(error instanceof BridgeRecoveryError && error.details !== undefined
|
|
4325
|
+
? { details: error.details }
|
|
4326
|
+
: {}),
|
|
3600
4327
|
},
|
|
3601
4328
|
};
|
|
3602
4329
|
}
|
|
@@ -3614,6 +4341,59 @@ function normalizeChangedPaths(paths) {
|
|
|
3614
4341
|
}
|
|
3615
4342
|
return [...new Set(normalized)].sort();
|
|
3616
4343
|
}
|
|
4344
|
+
function reviewedFiles(input) {
|
|
4345
|
+
const repoRoot = input.repoRoot ?? process.cwd();
|
|
4346
|
+
const files = input.files.map(({ path, rules }) => ({
|
|
4347
|
+
path: reviewedFilePath(path),
|
|
4348
|
+
rules: rules.map((rule) => normalizePersistentInput(rule, repoRoot)),
|
|
4349
|
+
}));
|
|
4350
|
+
const repeated = files
|
|
4351
|
+
.map(({ path }) => path)
|
|
4352
|
+
.filter((path, index, paths) => paths.indexOf(path) !== index);
|
|
4353
|
+
if (repeated.length > 0)
|
|
4354
|
+
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');
|
|
4355
|
+
return files;
|
|
4356
|
+
}
|
|
4357
|
+
function reviewedFilePath(path) {
|
|
4358
|
+
const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
4359
|
+
if (!normalized ||
|
|
4360
|
+
normalized.startsWith('/') ||
|
|
4361
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
4362
|
+
normalized.split('/').includes('..'))
|
|
4363
|
+
throw refuse(`Name each reviewed file by its repository-relative path, as Git reports it: ${path}`, 'task.self_review');
|
|
4364
|
+
return normalized;
|
|
4365
|
+
}
|
|
4366
|
+
function isDeviation(rule) {
|
|
4367
|
+
return rule.outcome === 'user_accepted_deviation';
|
|
4368
|
+
}
|
|
4369
|
+
export function ruleDeviationQuestionnaireId(taskId, path, rule) {
|
|
4370
|
+
return ('rule-deviation-' +
|
|
4371
|
+
sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
|
|
4372
|
+
}
|
|
4373
|
+
function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
4374
|
+
const copy = ruleDeviationCopy[language];
|
|
4375
|
+
return {
|
|
4376
|
+
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4377
|
+
message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
|
|
4378
|
+
language,
|
|
4379
|
+
options: [
|
|
4380
|
+
{ id: 'approve', label: copy.approve },
|
|
4381
|
+
{ id: 'change', label: copy.change },
|
|
4382
|
+
],
|
|
4383
|
+
};
|
|
4384
|
+
}
|
|
4385
|
+
const ruleDeviationCopy = {
|
|
4386
|
+
en: {
|
|
4387
|
+
message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
|
|
4388
|
+
approve: 'Keep the code as written and record the deviation',
|
|
4389
|
+
change: 'Change the code to follow the rule',
|
|
4390
|
+
},
|
|
4391
|
+
tr: {
|
|
4392
|
+
message: (path, resourceKey, issue) => `${path}, ${resourceKey} kuralına uymuyor: ${issue}`,
|
|
4393
|
+
approve: 'Kodu olduğu gibi bırak ve bu sapmayı kaydet',
|
|
4394
|
+
change: 'Kodu kurala uyacak şekilde değiştir',
|
|
4395
|
+
},
|
|
4396
|
+
};
|
|
3617
4397
|
function pathsContainAll(allowedPaths, actualPaths) {
|
|
3618
4398
|
const allowed = new Set(normalizeChangedPaths(allowedPaths));
|
|
3619
4399
|
return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
|
|
@@ -3694,6 +4474,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
|
|
|
3694
4474
|
});
|
|
3695
4475
|
}));
|
|
3696
4476
|
}
|
|
4477
|
+
function pendingQuestionnairesFields(value) {
|
|
4478
|
+
return {
|
|
4479
|
+
pendingQuestionnaires: value.mine,
|
|
4480
|
+
...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
3697
4483
|
function describePointer(pointer) {
|
|
3698
4484
|
return asJsonValue({
|
|
3699
4485
|
taskSlug: pointer.taskSlug,
|
|
@@ -3740,26 +4526,42 @@ function entryNextAction(authenticated, decision) {
|
|
|
3740
4526
|
return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
|
|
3741
4527
|
}
|
|
3742
4528
|
if (decision === 'bound') {
|
|
3743
|
-
return 'Continue into the lifecycle
|
|
4529
|
+
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
4530
|
}
|
|
3745
4531
|
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
4532
|
}
|
|
3747
|
-
function deliveryQuestion(touchedContract
|
|
4533
|
+
function deliveryQuestion(touchedContract, destination, publishApplicable) {
|
|
4534
|
+
const { branch, remote, pushUrl, remoteDefaultBranch, headCommit } = destination;
|
|
4535
|
+
const onto = branch ? `branch ${branch}` : 'a detached HEAD, which is on no branch';
|
|
4536
|
+
const target = branch && remote ? `${remote} (${pushUrl ?? 'no push URL'}) branch ${branch}` : null;
|
|
4537
|
+
const head = headCommit ? `, on top of ${headCommit.slice(0, 12)}` : '';
|
|
4538
|
+
const pushed = target ? `, and a push goes to ${target}` : '';
|
|
4539
|
+
const to = target ? ` to ${target}` : '';
|
|
4540
|
+
const blocked = target
|
|
4541
|
+
? ''
|
|
4542
|
+
: !branch
|
|
4543
|
+
? ' (not possible until the work is on a branch)'
|
|
4544
|
+
: ` (not possible: this clone has no remote that a push of ${branch} would go to)`;
|
|
4545
|
+
const base = 'base branch the pull request targets' +
|
|
4546
|
+
(remote && remoteDefaultBranch
|
|
4547
|
+
? `; the default branch of ${remote} is ${remoteDefaultBranch}`
|
|
4548
|
+
: '');
|
|
3748
4549
|
return asJsonValue({
|
|
3749
4550
|
required: true,
|
|
3750
|
-
question:
|
|
4551
|
+
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.`,
|
|
4552
|
+
destination,
|
|
3751
4553
|
options: [
|
|
3752
|
-
{ id: 'commit', label:
|
|
3753
|
-
{ id: 'commit_push', label:
|
|
4554
|
+
{ id: 'commit', label: `Commit on ${onto}` },
|
|
4555
|
+
{ id: 'commit_push', label: `Commit and push${to}${blocked}` },
|
|
3754
4556
|
{
|
|
3755
4557
|
id: 'commit_push_draft_pr',
|
|
3756
|
-
label:
|
|
3757
|
-
input:
|
|
4558
|
+
label: `Commit, push${to} and open a draft pull request${blocked}`,
|
|
4559
|
+
input: base,
|
|
3758
4560
|
},
|
|
3759
4561
|
{
|
|
3760
4562
|
id: 'commit_push_pr',
|
|
3761
|
-
label:
|
|
3762
|
-
input:
|
|
4563
|
+
label: `Commit, push${to} and open a pull request${blocked}`,
|
|
4564
|
+
input: base,
|
|
3763
4565
|
},
|
|
3764
4566
|
],
|
|
3765
4567
|
...(touchedContract
|
|
@@ -3768,6 +4570,9 @@ function deliveryQuestion(touchedContract = false) {
|
|
|
3768
4570
|
}
|
|
3769
4571
|
: {}),
|
|
3770
4572
|
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.',
|
|
4573
|
+
afterCommit: publishApplicable
|
|
4574
|
+
? 'Call memory.publish_task with this closed task id and repoRoot after the authorized commit is created.'
|
|
4575
|
+
: 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.',
|
|
3771
4576
|
});
|
|
3772
4577
|
}
|
|
3773
4578
|
function cleanJson(value) {
|
|
@@ -3790,6 +4595,73 @@ function objectValue(value) {
|
|
|
3790
4595
|
function objectOrEmpty(value) {
|
|
3791
4596
|
return objectValue(value ?? undefined) ?? {};
|
|
3792
4597
|
}
|
|
4598
|
+
function journalSummary(journal) {
|
|
4599
|
+
return {
|
|
4600
|
+
directory: journal.directory,
|
|
4601
|
+
appliedEventCount: journal.projection?.appliedEventIds.length ?? 0,
|
|
4602
|
+
pendingDeliveryCount: journal.pendingDeliveryCount,
|
|
4603
|
+
};
|
|
4604
|
+
}
|
|
4605
|
+
function outboxSummary(outbox) {
|
|
4606
|
+
const value = objectOrEmpty(outbox);
|
|
4607
|
+
return {
|
|
4608
|
+
synchronizedCount: Array.isArray(value.synchronized) ? value.synchronized.length : 0,
|
|
4609
|
+
blocked: Array.isArray(value.blocked) ? value.blocked : [],
|
|
4610
|
+
pending: typeof value.pending === 'number' ? value.pending : 0,
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
function documentViews(documents) {
|
|
4614
|
+
return (Array.isArray(documents) ? documents : []).flatMap((document) => {
|
|
4615
|
+
const value = objectValue(document);
|
|
4616
|
+
return value && typeof value.content === 'string' && value.content.trim()
|
|
4617
|
+
? [{ documentType: value.documentType ?? null, content: value.content }]
|
|
4618
|
+
: [];
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4621
|
+
function checkpointView(checkpoint) {
|
|
4622
|
+
if (!checkpoint)
|
|
4623
|
+
return null;
|
|
4624
|
+
const event = objectValue(checkpoint.event);
|
|
4625
|
+
return {
|
|
4626
|
+
event: event
|
|
4627
|
+
? {
|
|
4628
|
+
id: event.id ?? null,
|
|
4629
|
+
sequence: event.sequence ?? null,
|
|
4630
|
+
checkpointType: event.checkpointType ?? null,
|
|
4631
|
+
createdAt: event.createdAt ?? null,
|
|
4632
|
+
}
|
|
4633
|
+
: null,
|
|
4634
|
+
documents: documentViews(checkpoint.documents),
|
|
4635
|
+
};
|
|
4636
|
+
}
|
|
4637
|
+
function resumeBackendView(backend, includeDocuments) {
|
|
4638
|
+
const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
|
|
4639
|
+
const task = objectValue(backend.task);
|
|
4640
|
+
const selfReviewFiles = Array.isArray(task?.selfReviewFiles) ? task.selfReviewFiles : [];
|
|
4641
|
+
return {
|
|
4642
|
+
...rest,
|
|
4643
|
+
...(task
|
|
4644
|
+
? {
|
|
4645
|
+
task: {
|
|
4646
|
+
...Object.fromEntries(Object.entries(task).filter(([key]) => key !== 'selfReviewFiles')),
|
|
4647
|
+
selfReviewFileCount: selfReviewFiles.length,
|
|
4648
|
+
},
|
|
4649
|
+
}
|
|
4650
|
+
: {}),
|
|
4651
|
+
events: (Array.isArray(backend.events) ? backend.events : []).map((event) => {
|
|
4652
|
+
const value = objectOrEmpty(event);
|
|
4653
|
+
const payload = objectOrEmpty(value.payload ?? null);
|
|
4654
|
+
return {
|
|
4655
|
+
sequence: value.sequence ?? null,
|
|
4656
|
+
eventType: value.eventType ?? null,
|
|
4657
|
+
checkpointType: value.checkpointType ?? null,
|
|
4658
|
+
createdAt: value.createdAt ?? null,
|
|
4659
|
+
...(typeof payload.summary === 'string' ? { summary: payload.summary } : {}),
|
|
4660
|
+
};
|
|
4661
|
+
}),
|
|
4662
|
+
documents: includeDocuments ? documentViews(backend.documents) : [],
|
|
4663
|
+
};
|
|
4664
|
+
}
|
|
3793
4665
|
function archivedProjectGuidance(project) {
|
|
3794
4666
|
const projectId = typeof project?.id === 'string' ? project.id : null;
|
|
3795
4667
|
const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
|
|
@@ -3837,6 +4709,11 @@ function classifyError(error) {
|
|
|
3837
4709
|
}
|
|
3838
4710
|
return 'bridge_delivery_error';
|
|
3839
4711
|
}
|
|
4712
|
+
function backendRejectsField(error, fields) {
|
|
4713
|
+
return (error instanceof ApiResponseError &&
|
|
4714
|
+
error.httpStatus === 400 &&
|
|
4715
|
+
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
4716
|
+
}
|
|
3840
4717
|
function isDefinitiveAuthenticationFailure(error) {
|
|
3841
4718
|
return (error instanceof ApiResponseError &&
|
|
3842
4719
|
(error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
|
|
@@ -3914,24 +4791,49 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
3914
4791
|
return false;
|
|
3915
4792
|
}
|
|
3916
4793
|
}
|
|
3917
|
-
function
|
|
3918
|
-
return
|
|
4794
|
+
export function cancelledDeliveryQuestionId(entry, clean) {
|
|
4795
|
+
return ('worktree-delivery-cancel-' +
|
|
4796
|
+
sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
|
|
4797
|
+
}
|
|
4798
|
+
function refuse(message, recovery, details) {
|
|
4799
|
+
return new BridgeRecoveryError(message, recovery, details);
|
|
3919
4800
|
}
|
|
3920
4801
|
function short(value) {
|
|
3921
4802
|
return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
|
|
3922
4803
|
}
|
|
3923
|
-
function
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4804
|
+
function samplePaths(paths) {
|
|
4805
|
+
return paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
|
|
4806
|
+
}
|
|
4807
|
+
const contextRefreshConflicts = [
|
|
4808
|
+
'context_refresh_required',
|
|
4809
|
+
'approved_memory_revision_requires_refresh',
|
|
4810
|
+
];
|
|
4811
|
+
function contextRefreshAction(staleReason, baseline) {
|
|
4812
|
+
const reason = objectValue(staleReason);
|
|
4813
|
+
const cause = reason && typeof reason.resourceKey === 'string'
|
|
4814
|
+
? `Approved revision ${String(reason.revisionNumber)} of ${reason.resourceKey}${typeof reason.taskExternalId === 'string' ? ` (from ${reason.taskExternalId})` : ''} changed memory this task read.`
|
|
4815
|
+
: 'Approved memory changed what this task read.';
|
|
4816
|
+
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)}.` : ''}`;
|
|
4817
|
+
}
|
|
4818
|
+
function taskChangeSet(baseline, activeLease, current) {
|
|
4819
|
+
const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
|
|
4820
|
+
? activeLease.changedPaths.filter((path) => typeof path === 'string')
|
|
4821
|
+
: [];
|
|
4822
|
+
const wholeTreeDelta = manifestDelta(baseline.changedPaths, current);
|
|
4823
|
+
const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
|
|
4824
|
+
const taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
|
|
4825
|
+
return { leaseChangedPaths, wholeTreeDelta, leased, taskChanges };
|
|
4826
|
+
}
|
|
4827
|
+
function renderedPaths(paths, policy) {
|
|
4828
|
+
if (!policy)
|
|
4829
|
+
return [];
|
|
4830
|
+
return paths
|
|
4831
|
+
.filter((path) => policy.units.some((unit) => (unit.kind === 'screen_logic' || unit.kind === 'component_mapping') &&
|
|
4832
|
+
unit.patterns.some((pattern) => globMatches(path, pattern))))
|
|
4833
|
+
.sort();
|
|
4834
|
+
}
|
|
4835
|
+
function validationWaiverId(taskId, validationId, paths, reason) {
|
|
4836
|
+
return `validation-waiver-${validationId}-${sha256(stableStringify({ taskId, validationId, paths, reason })).slice(0, 32)}`;
|
|
3935
4837
|
}
|
|
3936
4838
|
function repairChangeBaseline(local, recordedDiffHash, head) {
|
|
3937
4839
|
if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
|
|
@@ -4122,7 +5024,7 @@ function globMatches(path, pattern) {
|
|
|
4122
5024
|
}
|
|
4123
5025
|
return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
|
|
4124
5026
|
}
|
|
4125
|
-
function
|
|
5027
|
+
function newResourceEvidenceFindings(candidates, evidence) {
|
|
4126
5028
|
const normalizedEvidence = evidence.map((entry) => ({
|
|
4127
5029
|
...entry,
|
|
4128
5030
|
path: normalizeChangedPaths([entry.path])[0],
|
|
@@ -4133,47 +5035,56 @@ function assertNewResourceEvidence(candidates, evidence) {
|
|
|
4133
5035
|
if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
|
|
4134
5036
|
throw refuse('New resource evidence contains duplicate paths', 'task.verify');
|
|
4135
5037
|
}
|
|
4136
|
-
const
|
|
4137
|
-
const
|
|
4138
|
-
|
|
4139
|
-
.
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
5038
|
+
const sent = new Map(normalizedEvidence.map((entry) => [entry.path, entry.kind]));
|
|
5039
|
+
const expected = new Map(candidates.map((entry) => [entry.path, entry.kind]));
|
|
5040
|
+
const missing = candidates
|
|
5041
|
+
.filter((entry) => !sent.has(entry.path))
|
|
5042
|
+
.map((entry) => `${entry.path} (${entry.kind})`)
|
|
5043
|
+
.sort();
|
|
5044
|
+
const extra = normalizedEvidence
|
|
5045
|
+
.filter((entry) => !expected.has(entry.path))
|
|
5046
|
+
.map((entry) => `${entry.path} (${entry.kind})`)
|
|
5047
|
+
.sort();
|
|
5048
|
+
const mismatched = normalizedEvidence
|
|
5049
|
+
.filter((entry) => expected.has(entry.path) && expected.get(entry.path) !== entry.kind)
|
|
5050
|
+
.map((entry) => `${entry.path} (sent ${entry.kind}, policy ${expected.get(entry.path)})`)
|
|
5051
|
+
.sort();
|
|
5052
|
+
const details = [
|
|
5053
|
+
missing.length ? `missing evidence for ${missing.join(', ')}` : '',
|
|
5054
|
+
extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
|
|
5055
|
+
mismatched.length
|
|
5056
|
+
? `kinds that do not match the project discovery policy: ${mismatched.join(', ')}`
|
|
5057
|
+
: '',
|
|
5058
|
+
].filter(Boolean);
|
|
5059
|
+
return details.length > 0
|
|
5060
|
+
? [
|
|
5061
|
+
`New screen or shared component paths require exact memory reconciliation. ${details.join('; ')}.`,
|
|
5062
|
+
]
|
|
5063
|
+
: [];
|
|
4155
5064
|
}
|
|
4156
|
-
function
|
|
5065
|
+
function validationEvidenceFindings(validations) {
|
|
4157
5066
|
if (validations.length === 0) {
|
|
4158
|
-
|
|
5067
|
+
return ['Validation evidence is required'];
|
|
4159
5068
|
}
|
|
4160
5069
|
const allowed = new Set(validationIds);
|
|
4161
5070
|
const seen = new Set();
|
|
5071
|
+
const findings = [];
|
|
4162
5072
|
for (const validation of validations) {
|
|
4163
5073
|
if (!allowed.has(validation.validationId)) {
|
|
4164
|
-
|
|
5074
|
+
findings.push(`Unknown validation evidence: ${validation.validationId}`);
|
|
4165
5075
|
}
|
|
4166
|
-
if (seen.has(validation.validationId)) {
|
|
4167
|
-
|
|
5076
|
+
else if (seen.has(validation.validationId)) {
|
|
5077
|
+
findings.push(`Duplicate validation evidence: ${validation.validationId}`);
|
|
4168
5078
|
}
|
|
4169
5079
|
if (!validation.command.trim()) {
|
|
4170
|
-
|
|
5080
|
+
findings.push(`Validation command is required: ${validation.validationId}`);
|
|
4171
5081
|
}
|
|
4172
5082
|
if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
|
|
4173
|
-
|
|
5083
|
+
findings.push(`Validation output hash is invalid: ${validation.validationId}`);
|
|
4174
5084
|
}
|
|
4175
5085
|
seen.add(validation.validationId);
|
|
4176
5086
|
}
|
|
5087
|
+
return findings;
|
|
4177
5088
|
}
|
|
4178
5089
|
function sourceFromContext(value) {
|
|
4179
5090
|
const context = objectValue(value);
|