engineering-memory 1.11.8 → 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 +7 -3
- package/install/codex-approval.mjs +457 -0
- package/install/files.mjs +119 -9
- package/install/installer.mjs +44 -5
- package/install/launcher.mjs +95 -0
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +4 -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/index.js +25 -81
- 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/server.js +14 -7
- 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/tool-host.js +12 -0
- 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 +1163 -231
- package/runtime/dist/src/runtime/create-bridge-service.js +82 -0
- 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 +24 -0
- package/runtime/dist/src/runtime/runtime-host.js +263 -0
- 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 +340 -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
|
-
worktree: this.
|
|
463
|
-
pendingQuestionnaires,
|
|
578
|
+
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
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
|
-
worktree: this.
|
|
646
|
-
localJournal,
|
|
647
|
-
pendingQuestionnaires,
|
|
778
|
+
worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
|
|
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,
|
|
@@ -741,7 +918,7 @@ export class BridgeService {
|
|
|
741
918
|
transitionTaskVersion = refreshed.taskVersion;
|
|
742
919
|
}
|
|
743
920
|
if (decision && this.dependencies.worktreePool) {
|
|
744
|
-
const allocation = this.
|
|
921
|
+
const allocation = await this.ownedAllocation(pointer.projectId, repository.repoRoot);
|
|
745
922
|
if (!allocation || allocation.externalTaskId !== pointer.taskSlug)
|
|
746
923
|
throw refuse('Choose this task branch and use its allocated repoRoot before transitioning to write.', 'task.branch');
|
|
747
924
|
await this.dependencies.worktreePool.assertOwnership(pointer.projectId, repository.repoRoot, allocation.generation);
|
|
@@ -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 }
|
|
@@ -802,16 +979,14 @@ export class BridgeService {
|
|
|
802
979
|
await this.dependencies.repositories.git
|
|
803
980
|
.branchStore(repository.repoRoot)
|
|
804
981
|
.bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
|
|
982
|
+
const allocation = await this.ownedAllocation(preparedPointer.projectId, repository.repoRoot);
|
|
805
983
|
await this.dependencies.activeContexts.save({
|
|
806
984
|
...preparedPointer,
|
|
807
985
|
worktreeId: sha256(repository.repoRoot),
|
|
808
986
|
branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
|
|
809
987
|
mode: 'write',
|
|
810
|
-
...(
|
|
811
|
-
? { worktreeGeneration: this.poolAllocations.get(repository.repoRoot).generation }
|
|
812
|
-
: {}),
|
|
988
|
+
...(allocation ? { worktreeGeneration: allocation.generation } : {}),
|
|
813
989
|
});
|
|
814
|
-
const allocation = this.poolAllocations.get(repository.repoRoot);
|
|
815
990
|
if (allocation && this.dependencies.worktreePool) {
|
|
816
991
|
await this.dependencies.worktreePool.bind(preparedPointer.projectId, repository.repoRoot, allocation.generation, preparedPointer.taskId);
|
|
817
992
|
allocation.taskId = preparedPointer.taskId;
|
|
@@ -964,13 +1139,14 @@ export class BridgeService {
|
|
|
964
1139
|
if (pointer && repository && pointer.mode !== 'read_only')
|
|
965
1140
|
await this.assertPoolPointer(repository, pointer);
|
|
966
1141
|
const source = repository
|
|
967
|
-
? await this.checkoutSource(repository, pointer?.source)
|
|
1142
|
+
? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
|
|
968
1143
|
: undefined;
|
|
969
1144
|
const response = await this.dependencies.client.request(endpoints.contextRefresh, {
|
|
970
1145
|
method: 'POST',
|
|
971
1146
|
body: cleanJson({ sessionId: input.sessionId, source }),
|
|
972
1147
|
});
|
|
973
|
-
|
|
1148
|
+
for (const conflict of contextRefreshConflicts)
|
|
1149
|
+
await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, conflict);
|
|
974
1150
|
return asJsonValue({
|
|
975
1151
|
...objectOrEmpty(response.data),
|
|
976
1152
|
requiresPrepareChange: true,
|
|
@@ -986,7 +1162,9 @@ export class BridgeService {
|
|
|
986
1162
|
cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
|
|
987
1163
|
allowStaleOnUnavailable: true,
|
|
988
1164
|
});
|
|
989
|
-
return
|
|
1165
|
+
return Array.isArray(response.data)
|
|
1166
|
+
? asJsonValue({ resources: response.data })
|
|
1167
|
+
: response.data;
|
|
990
1168
|
});
|
|
991
1169
|
}
|
|
992
1170
|
async memoryHistory(input) {
|
|
@@ -994,15 +1172,31 @@ export class BridgeService {
|
|
|
994
1172
|
const body = cleanJson({
|
|
995
1173
|
sessionId: input.sessionId,
|
|
996
1174
|
resourceKeys: input.resourceKeys,
|
|
1175
|
+
resourceIds: input.resourceIds,
|
|
997
1176
|
paths: input.paths,
|
|
1177
|
+
required: input.required,
|
|
998
1178
|
depth: input.depth,
|
|
999
1179
|
});
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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);
|
|
1004
1193
|
const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
|
|
1005
|
-
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
|
+
});
|
|
1006
1200
|
});
|
|
1007
1201
|
}
|
|
1008
1202
|
async branchTaskReferences(repoRoot, paths) {
|
|
@@ -1343,7 +1537,7 @@ export class BridgeService {
|
|
|
1343
1537
|
async memoryListProposals(input) {
|
|
1344
1538
|
return await this.execute(async () => {
|
|
1345
1539
|
const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
|
|
1346
|
-
return asJsonValue(
|
|
1540
|
+
return asJsonValue(response.data);
|
|
1347
1541
|
});
|
|
1348
1542
|
}
|
|
1349
1543
|
async memoryReviewProposal(input) {
|
|
@@ -1418,16 +1612,25 @@ export class BridgeService {
|
|
|
1418
1612
|
}
|
|
1419
1613
|
return [...pending];
|
|
1420
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
|
+
}
|
|
1421
1624
|
async taskSelfReview(input) {
|
|
1422
1625
|
return await this.execute(async () => {
|
|
1423
|
-
const
|
|
1424
|
-
assertSafeToPersist(cleanJson({
|
|
1425
|
-
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));
|
|
1426
1630
|
if (this.deferDeliveries) {
|
|
1427
1631
|
return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
|
|
1428
1632
|
diffHash: repository.git.diffHash,
|
|
1429
|
-
|
|
1430
|
-
findings,
|
|
1633
|
+
files,
|
|
1431
1634
|
}));
|
|
1432
1635
|
}
|
|
1433
1636
|
const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
|
|
@@ -1437,20 +1640,50 @@ export class BridgeService {
|
|
|
1437
1640
|
taskId: input.taskId,
|
|
1438
1641
|
expectedTaskVersion: snapshot.taskVersion,
|
|
1439
1642
|
diffHash: repository.git.diffHash,
|
|
1440
|
-
|
|
1441
|
-
findings,
|
|
1643
|
+
files,
|
|
1442
1644
|
}),
|
|
1443
1645
|
});
|
|
1444
1646
|
await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
|
|
1445
1647
|
return asJsonValue({
|
|
1446
1648
|
selfReview: {
|
|
1447
1649
|
diffHash: repository.git.diffHash,
|
|
1448
|
-
|
|
1449
|
-
|
|
1650
|
+
files: files.map(({ path, rules }) => ({
|
|
1651
|
+
path,
|
|
1652
|
+
rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
|
|
1653
|
+
})),
|
|
1450
1654
|
},
|
|
1451
1655
|
});
|
|
1452
1656
|
});
|
|
1453
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
|
+
}
|
|
1454
1687
|
async taskReconcile(input) {
|
|
1455
1688
|
return await this.execute(async () => {
|
|
1456
1689
|
const repoRoot = input.repoRoot ?? process.cwd();
|
|
@@ -1569,7 +1802,7 @@ export class BridgeService {
|
|
|
1569
1802
|
method: 'POST',
|
|
1570
1803
|
body: cleanJson({
|
|
1571
1804
|
sessionId: pointer.sessionId,
|
|
1572
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
1805
|
+
source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
|
|
1573
1806
|
}),
|
|
1574
1807
|
});
|
|
1575
1808
|
const refreshData = objectValue(refreshed.data);
|
|
@@ -1594,13 +1827,18 @@ export class BridgeService {
|
|
|
1594
1827
|
}
|
|
1595
1828
|
async taskVerify(input) {
|
|
1596
1829
|
return await this.execute(async () => {
|
|
1830
|
+
const timer = startPhaseTimer('task.verify');
|
|
1597
1831
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1598
1832
|
await this.recoverJournalOutbox();
|
|
1833
|
+
timer.mark('recover_journal_outbox');
|
|
1599
1834
|
const outbox = await this.flushOutbox(input.taskId);
|
|
1835
|
+
timer.mark('flush_outbox');
|
|
1600
1836
|
const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
|
|
1601
1837
|
.length;
|
|
1602
|
-
|
|
1838
|
+
let repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1839
|
+
timer.mark('resolve_repository');
|
|
1603
1840
|
if (pendingOutboxCount > 0) {
|
|
1841
|
+
timer.finish();
|
|
1604
1842
|
return asJsonValue({
|
|
1605
1843
|
verified: false,
|
|
1606
1844
|
reason: 'pending_outbox',
|
|
@@ -1610,6 +1848,7 @@ export class BridgeService {
|
|
|
1610
1848
|
});
|
|
1611
1849
|
}
|
|
1612
1850
|
const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
|
|
1851
|
+
repository = await this.taskRepository(repository, pointer);
|
|
1613
1852
|
const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
|
|
1614
1853
|
if (pendingCorrectionScopes.length > 0) {
|
|
1615
1854
|
return asJsonValue({
|
|
@@ -1642,7 +1881,7 @@ export class BridgeService {
|
|
|
1642
1881
|
body: {
|
|
1643
1882
|
sessionId: input.sessionId,
|
|
1644
1883
|
repoFingerprint: repository.repoFingerprint,
|
|
1645
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
1884
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1646
1885
|
afterSequence: 0,
|
|
1647
1886
|
},
|
|
1648
1887
|
});
|
|
@@ -1663,6 +1902,10 @@ export class BridgeService {
|
|
|
1663
1902
|
await this.assertTaskWorktree(repository, snapshotTask, pointer);
|
|
1664
1903
|
const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
|
|
1665
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
|
+
}
|
|
1666
1909
|
if (taskMode === 'read_only') {
|
|
1667
1910
|
if (input.leaseId || input.changedPaths?.length) {
|
|
1668
1911
|
throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
|
|
@@ -1670,48 +1913,114 @@ export class BridgeService {
|
|
|
1670
1913
|
}
|
|
1671
1914
|
else {
|
|
1672
1915
|
const baseline = pointer.changeBaseline;
|
|
1673
|
-
const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
|
|
1674
|
-
? activeLease.changedPaths.filter((path) => typeof path === 'string')
|
|
1675
|
-
: [];
|
|
1676
1916
|
if (!baseline) {
|
|
1677
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');
|
|
1678
1918
|
}
|
|
1679
|
-
if (activeLease
|
|
1680
|
-
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');
|
|
1681
1921
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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');
|
|
1924
|
+
}
|
|
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]))
|
|
1687
1929
|
.map((entry) => entry.path)
|
|
1688
1930
|
.sort();
|
|
1689
1931
|
taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
+
});
|
|
1694
1977
|
}
|
|
1695
1978
|
if (taskChangedPaths.length === 0) {
|
|
1696
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');
|
|
1697
1980
|
}
|
|
1698
|
-
if (!
|
|
1699
|
-
throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease
|
|
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');
|
|
1700
1983
|
}
|
|
1701
|
-
const unleased = taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
|
|
1702
|
-
!pathsContainAll(leaseChangedPaths, [path]));
|
|
1703
|
-
if (unleased.length > 0) {
|
|
1704
|
-
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');
|
|
1705
|
-
}
|
|
1706
|
-
assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
|
|
1707
1984
|
}
|
|
1708
1985
|
assertSafeToPersist(cleanJson(validations));
|
|
1709
|
-
|
|
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
|
+
}
|
|
1710
2019
|
const pathChangeManifest = taskMode === 'read_only'
|
|
1711
2020
|
? []
|
|
1712
2021
|
: await buildPathChangeManifest(repository.repoRoot, taskChanges);
|
|
1713
2022
|
const verifyBody = cleanJson({
|
|
1714
|
-
source: await this.checkoutSource(repository, pointer.source),
|
|
2023
|
+
source: await this.checkoutSource(repository, pointer.source, pointer),
|
|
1715
2024
|
taskId: input.taskId,
|
|
1716
2025
|
sessionId: input.sessionId,
|
|
1717
2026
|
expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
|
|
@@ -1731,6 +2040,7 @@ export class BridgeService {
|
|
|
1731
2040
|
: {}),
|
|
1732
2041
|
...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
|
|
1733
2042
|
validations,
|
|
2043
|
+
...(validationWaivers.length > 0 ? { validationWaivers } : {}),
|
|
1734
2044
|
pendingOutboxCount,
|
|
1735
2045
|
newResources: input.newResources,
|
|
1736
2046
|
introducedStructures: input.introducedStructures,
|
|
@@ -1751,6 +2061,8 @@ export class BridgeService {
|
|
|
1751
2061
|
if (!isBackendUnavailableError(error)) {
|
|
1752
2062
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1753
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');
|
|
1754
2066
|
throw error;
|
|
1755
2067
|
}
|
|
1756
2068
|
const responseData = objectValue(response.data);
|
|
@@ -1774,6 +2086,7 @@ export class BridgeService {
|
|
|
1774
2086
|
else {
|
|
1775
2087
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
1776
2088
|
}
|
|
2089
|
+
timer.finish();
|
|
1777
2090
|
return asJsonValue({
|
|
1778
2091
|
...objectOrEmpty(response.data),
|
|
1779
2092
|
mode: taskMode,
|
|
@@ -1785,11 +2098,45 @@ export class BridgeService {
|
|
|
1785
2098
|
});
|
|
1786
2099
|
});
|
|
1787
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
|
+
}
|
|
1788
2134
|
async taskClose(input) {
|
|
1789
2135
|
return await this.execute(async () => {
|
|
1790
2136
|
return await this.taskExclusive(input.taskId, async () => {
|
|
1791
|
-
const
|
|
1792
|
-
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);
|
|
1793
2140
|
if (pointer.closeIntent) {
|
|
1794
2141
|
return await this.retryCloseIntent(repository, pointer);
|
|
1795
2142
|
}
|
|
@@ -1798,7 +2145,7 @@ export class BridgeService {
|
|
|
1798
2145
|
body: {
|
|
1799
2146
|
sessionId: pointer.sessionId,
|
|
1800
2147
|
repoFingerprint: repository.repoFingerprint,
|
|
1801
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2148
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1802
2149
|
afterSequence: 0,
|
|
1803
2150
|
},
|
|
1804
2151
|
});
|
|
@@ -1812,7 +2159,7 @@ export class BridgeService {
|
|
|
1812
2159
|
: [];
|
|
1813
2160
|
await this.assertTaskWorktree(repository, task, pointer);
|
|
1814
2161
|
const closeBody = {
|
|
1815
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
2162
|
+
source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
|
|
1816
2163
|
taskId: input.taskId,
|
|
1817
2164
|
sessionId: pointer.sessionId,
|
|
1818
2165
|
expectedTaskVersion: numericTaskVersion(task.lockVersion),
|
|
@@ -1833,7 +2180,7 @@ export class BridgeService {
|
|
|
1833
2180
|
}
|
|
1834
2181
|
async worktreeTaskAllocation(input) {
|
|
1835
2182
|
return this.execute(async () => {
|
|
1836
|
-
const repository = await this.dependencies.repositories.
|
|
2183
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
1837
2184
|
const allocation = repository.projectId && this.dependencies.worktreePool
|
|
1838
2185
|
? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
|
|
1839
2186
|
: null;
|
|
@@ -1842,18 +2189,68 @@ export class BridgeService {
|
|
|
1842
2189
|
}
|
|
1843
2190
|
async projectGitPreferences(input) {
|
|
1844
2191
|
return this.execute(async () => {
|
|
1845
|
-
const repository = await this.dependencies.repositories.
|
|
2192
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd(), input.projectId);
|
|
1846
2193
|
if (!repository.projectId)
|
|
1847
2194
|
throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
|
|
1848
2195
|
const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
|
|
1849
2196
|
return asJsonValue({
|
|
1850
2197
|
...objectOrEmpty(objectValue(response.data)),
|
|
1851
2198
|
repoRoot: repository.repoRoot,
|
|
1852
|
-
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,
|
|
1853
2229
|
currentCommit: repository.git.head,
|
|
2230
|
+
folder,
|
|
2231
|
+
suggestedName,
|
|
1854
2232
|
});
|
|
1855
2233
|
});
|
|
1856
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
|
+
}
|
|
1857
2254
|
async setProjectGitPreferences(projectId, body) {
|
|
1858
2255
|
return this.execute(async () => {
|
|
1859
2256
|
assertSafeToPersist(body);
|
|
@@ -1889,18 +2286,43 @@ export class BridgeService {
|
|
|
1889
2286
|
}
|
|
1890
2287
|
async worktreeOperation(operation, input) {
|
|
1891
2288
|
return this.execute(async () => {
|
|
1892
|
-
const
|
|
2289
|
+
const timer = startPhaseTimer(`worktree.${operation}`);
|
|
2290
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
|
|
2291
|
+
timer.mark('resolve_identity');
|
|
1893
2292
|
const projectId = repository.projectId;
|
|
1894
2293
|
const pool = this.dependencies.worktreePool;
|
|
1895
2294
|
if (!projectId || !pool)
|
|
1896
2295
|
throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
|
|
1897
2296
|
if (operation === 'list' || operation === 'reconcile') {
|
|
1898
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();
|
|
1899
2321
|
return asJsonValue({
|
|
1900
|
-
items
|
|
1901
|
-
selected
|
|
2322
|
+
items,
|
|
2323
|
+
selected,
|
|
1902
2324
|
managedRoot,
|
|
1903
|
-
...(managedRoot.problem || managedRoot.synced
|
|
2325
|
+
...(managedRoot.problem || managedRoot.synced || retired
|
|
1904
2326
|
? {
|
|
1905
2327
|
notice: [
|
|
1906
2328
|
...(managedRoot.problem ? [managedRoot.problem] : []),
|
|
@@ -1909,6 +2331,11 @@ export class BridgeService {
|
|
|
1909
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.`,
|
|
1910
2332
|
]
|
|
1911
2333
|
: []),
|
|
2334
|
+
...(retired
|
|
2335
|
+
? [
|
|
2336
|
+
`Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
|
|
2337
|
+
]
|
|
2338
|
+
: []),
|
|
1912
2339
|
].join(' '),
|
|
1913
2340
|
}
|
|
1914
2341
|
: {}),
|
|
@@ -1936,29 +2363,96 @@ export class BridgeService {
|
|
|
1936
2363
|
await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
|
|
1937
2364
|
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1938
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
|
+
}
|
|
1939
2381
|
const entry = await pool.forPath(projectId, repository.repoRoot);
|
|
1940
2382
|
if (!entry ||
|
|
1941
2383
|
entry.externalTaskId !== input.externalTaskId ||
|
|
1942
2384
|
entry.generation !== input.generation)
|
|
1943
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');
|
|
1944
2391
|
const perform = async () => {
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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 });
|
|
1950
2433
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
await this.flushOutbox(entry.taskId);
|
|
1954
|
-
await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
|
|
1955
|
-
this.poolAllocations.delete(repository.repoRoot);
|
|
2434
|
+
finally {
|
|
2435
|
+
timer.finish();
|
|
1956
2436
|
}
|
|
1957
|
-
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
1958
2437
|
};
|
|
1959
2438
|
return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
|
|
1960
2439
|
});
|
|
1961
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
|
+
}
|
|
1962
2456
|
async pooledTaskBranch(repository, input) {
|
|
1963
2457
|
const pool = this.dependencies.worktreePool;
|
|
1964
2458
|
const projectId = repository.projectId;
|
|
@@ -1969,24 +2463,72 @@ export class BridgeService {
|
|
|
1969
2463
|
if (existing)
|
|
1970
2464
|
source = { sourceCommit: existing.baseCommit };
|
|
1971
2465
|
else {
|
|
1972
|
-
if (!input.decisionId || !input.base)
|
|
2466
|
+
if (!input.decisionId || !input.startId || !input.base)
|
|
1973
2467
|
throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
|
|
1974
|
-
const
|
|
2468
|
+
const start = taskStartChoice(await this.questionnaireResume({
|
|
1975
2469
|
repoRoot: repository.repoRoot,
|
|
1976
|
-
questionnaireId: input.
|
|
1977
|
-
});
|
|
1978
|
-
const
|
|
2470
|
+
questionnaireId: input.startId,
|
|
2471
|
+
}));
|
|
2472
|
+
const request = {
|
|
1979
2473
|
externalTaskId: input.externalTaskId,
|
|
1980
2474
|
base: input.base,
|
|
1981
2475
|
name: input.name ?? null,
|
|
1982
2476
|
keepCurrent: input.keepCurrent === true,
|
|
1983
2477
|
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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;
|
|
1988
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
|
+
}
|
|
1989
2528
|
}
|
|
2529
|
+
const allocationTimer = startPhaseTimer('task.branch.allocate');
|
|
2530
|
+
const allocationPolicy = await this.poolPolicy(true);
|
|
2531
|
+
allocationTimer.mark('policy');
|
|
1990
2532
|
const allocation = await pool.allocate({
|
|
1991
2533
|
projectId,
|
|
1992
2534
|
projectName: preferences.projectName ?? projectId,
|
|
@@ -1996,8 +2538,11 @@ export class BridgeService {
|
|
|
1996
2538
|
branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
|
|
1997
2539
|
baseCommit: source.sourceCommit,
|
|
1998
2540
|
keepCurrent: input.keepCurrent,
|
|
2541
|
+
inPlace: !existing && input.inPlace,
|
|
1999
2542
|
resume: Boolean(existing),
|
|
2000
|
-
},
|
|
2543
|
+
}, allocationPolicy);
|
|
2544
|
+
allocationTimer.mark('pool_allocate');
|
|
2545
|
+
allocationTimer.finish();
|
|
2001
2546
|
this.poolAllocations.set(allocation.repoRoot, allocation);
|
|
2002
2547
|
if (existing && allocation.repoRoot === repository.repoRoot) {
|
|
2003
2548
|
const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
|
|
@@ -2020,6 +2565,25 @@ export class BridgeService {
|
|
|
2020
2565
|
nextAction: 'Run every file, terminal, lifecycle and delivery operation from this repoRoot. Use task.heartbeat during long work. Pause before handing the task to another client.',
|
|
2021
2566
|
});
|
|
2022
2567
|
}
|
|
2568
|
+
adoptOwned(repoRoot, entry) {
|
|
2569
|
+
const cached = this.poolAllocations.get(repoRoot);
|
|
2570
|
+
if (cached)
|
|
2571
|
+
return cached;
|
|
2572
|
+
const pool = this.dependencies.worktreePool;
|
|
2573
|
+
if (!entry || !pool || !pool.owns(entry))
|
|
2574
|
+
return undefined;
|
|
2575
|
+
this.poolAllocations.set(repoRoot, entry);
|
|
2576
|
+
return entry;
|
|
2577
|
+
}
|
|
2578
|
+
async ownedAllocation(projectId, repoRoot) {
|
|
2579
|
+
const cached = this.poolAllocations.get(repoRoot);
|
|
2580
|
+
if (cached)
|
|
2581
|
+
return cached;
|
|
2582
|
+
const pool = this.dependencies.worktreePool;
|
|
2583
|
+
if (!pool)
|
|
2584
|
+
return undefined;
|
|
2585
|
+
return this.adoptOwned(repoRoot, await pool.forPath(projectId, repoRoot));
|
|
2586
|
+
}
|
|
2023
2587
|
async assertPoolPointer(repository, pointer) {
|
|
2024
2588
|
const pool = this.dependencies.worktreePool;
|
|
2025
2589
|
if (!pool)
|
|
@@ -2029,7 +2593,7 @@ export class BridgeService {
|
|
|
2029
2593
|
return; // existing unmanaged tasks retain their legacy reservation
|
|
2030
2594
|
if (entry && entry.externalTaskId !== pointer.taskSlug)
|
|
2031
2595
|
throw refuse(`This directory is allocated to task ${entry.externalTaskId}. Resume this task from its own worktree, or reconcile the pool to release the directory.`, 'worktree.reconcile');
|
|
2032
|
-
const owned = this.
|
|
2596
|
+
const owned = this.adoptOwned(repository.repoRoot, entry);
|
|
2033
2597
|
if (!entry || !owned || owned.generation !== pointer.worktreeGeneration)
|
|
2034
2598
|
throw refuse('Resume this task to acquire its worktree before writing or delivering.', 'session.resume');
|
|
2035
2599
|
await pool.assertOwnership(pointer.projectId, repository.repoRoot, owned.generation);
|
|
@@ -2050,8 +2614,9 @@ export class BridgeService {
|
|
|
2050
2614
|
(await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
|
|
2051
2615
|
return this.pooledTaskBranch(repository, input);
|
|
2052
2616
|
if (Boolean(input.name) === (input.keepCurrent === true) ||
|
|
2053
|
-
(input.worktreePath && !input.name)
|
|
2054
|
-
|
|
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');
|
|
2055
2620
|
assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
|
|
2056
2621
|
if (this.dependencies.worktreePool)
|
|
2057
2622
|
return this.pooledTaskBranch(repository, input);
|
|
@@ -2107,7 +2672,24 @@ export class BridgeService {
|
|
|
2107
2672
|
async requireBranchDecision(repository, externalTaskId) {
|
|
2108
2673
|
const git = this.dependencies.repositories.git;
|
|
2109
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
|
+
}
|
|
2110
2688
|
let record = await store.read();
|
|
2689
|
+
if (record &&
|
|
2690
|
+
(record.decision.projectId !== repository.projectId ||
|
|
2691
|
+
record.decision.externalTaskId !== externalTaskId))
|
|
2692
|
+
record = null;
|
|
2111
2693
|
if (!record) {
|
|
2112
2694
|
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
|
|
2113
2695
|
if (pointer?.worktreeId === sha256(repository.repoRoot) &&
|
|
@@ -2123,15 +2705,102 @@ export class BridgeService {
|
|
|
2123
2705
|
}
|
|
2124
2706
|
}
|
|
2125
2707
|
if (!record)
|
|
2126
|
-
throw refuse(`
|
|
2127
|
-
if (record.decision.projectId !== repository.projectId ||
|
|
2128
|
-
record.decision.externalTaskId !== externalTaskId)
|
|
2129
|
-
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');
|
|
2130
2709
|
const branch = await git.currentBranch(repository.repoRoot);
|
|
2131
2710
|
if (branch !== record.decision.branch)
|
|
2132
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');
|
|
2133
2712
|
return record.decision;
|
|
2134
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
|
+
}
|
|
2135
2804
|
async assertTaskWorktree(repository, task, pointer) {
|
|
2136
2805
|
if (normalizeTaskMode(task.mode) === 'read_only')
|
|
2137
2806
|
return;
|
|
@@ -2156,9 +2825,14 @@ export class BridgeService {
|
|
|
2156
2825
|
if (!abandoned || abandoned.abandoned !== true) {
|
|
2157
2826
|
throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
|
|
2158
2827
|
}
|
|
2159
|
-
const allocation =
|
|
2828
|
+
const allocation = repository.projectId
|
|
2829
|
+
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
2830
|
+
: this.poolAllocations.get(repository.repoRoot);
|
|
2160
2831
|
if (allocation && this.dependencies.worktreePool) {
|
|
2161
|
-
|
|
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);
|
|
2162
2836
|
this.poolAllocations.delete(repository.repoRoot);
|
|
2163
2837
|
}
|
|
2164
2838
|
else
|
|
@@ -2592,10 +3266,13 @@ export class BridgeService {
|
|
|
2592
3266
|
}
|
|
2593
3267
|
async sessionEntry(input = {}) {
|
|
2594
3268
|
return await this.execute(async () => {
|
|
3269
|
+
const timer = startPhaseTimer('session.entry');
|
|
2595
3270
|
const authentication = await this.dependencies.browserAuth.status();
|
|
3271
|
+
timer.mark('auth_status');
|
|
2596
3272
|
let repository;
|
|
2597
3273
|
try {
|
|
2598
3274
|
repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
3275
|
+
timer.mark('resolve_repository');
|
|
2599
3276
|
}
|
|
2600
3277
|
catch (error) {
|
|
2601
3278
|
const missingTarget = error.code === 'ENOENT';
|
|
@@ -2617,32 +3294,44 @@ export class BridgeService {
|
|
|
2617
3294
|
});
|
|
2618
3295
|
}
|
|
2619
3296
|
const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
|
|
3297
|
+
timer.mark('load_decision');
|
|
2620
3298
|
const authenticated = authentication.authenticated === true;
|
|
2621
3299
|
const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
|
|
2622
|
-
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);
|
|
2623
3325
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
2624
3326
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
2625
|
-
const projectId = repository.projectId;
|
|
2626
|
-
const liveTasks = authenticated && state === 'bound'
|
|
2627
|
-
? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
|
|
2628
|
-
: [];
|
|
2629
|
-
const workItems = authenticated && state === 'bound' && projectId
|
|
2630
|
-
? await this.actionableWorkItems(projectId)
|
|
2631
|
-
: [];
|
|
2632
|
-
const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
2633
|
-
? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
|
|
2634
|
-
: null;
|
|
2635
3327
|
const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
|
|
2636
3328
|
const unansweredBranches = preferences?.canManage === true
|
|
2637
3329
|
? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
|
|
2638
3330
|
.filter(([, value]) => objectValue(value)?.answered === false)
|
|
2639
3331
|
.map(([role]) => role)
|
|
2640
3332
|
: [];
|
|
2641
|
-
const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
|
|
2642
|
-
? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
|
|
2643
|
-
: null;
|
|
2644
3333
|
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
2645
|
-
|
|
3334
|
+
timer.finish();
|
|
2646
3335
|
return asJsonValue({
|
|
2647
3336
|
authenticated,
|
|
2648
3337
|
repository: publicRepository(repository),
|
|
@@ -2657,21 +3346,24 @@ export class BridgeService {
|
|
|
2657
3346
|
gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
|
|
2658
3347
|
unansweredBranchPreferences: unansweredBranches,
|
|
2659
3348
|
branchPreferenceAction: unansweredBranches.length
|
|
2660
|
-
? '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.'
|
|
2661
3350
|
: 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
|
|
2662
3351
|
}
|
|
2663
3352
|
: {}),
|
|
2664
|
-
...(state !== 'disabled' ?
|
|
3353
|
+
...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
|
|
3354
|
+
...(pendingQuestionnaires.deferred.length > 0
|
|
3355
|
+
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
3356
|
+
: {}),
|
|
2665
3357
|
client,
|
|
2666
3358
|
...(incomplete && state === 'bound'
|
|
2667
3359
|
? {
|
|
2668
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.',
|
|
2669
3361
|
}
|
|
2670
3362
|
: {}),
|
|
2671
|
-
nextAction: pendingQuestionnaires.length > 0
|
|
3363
|
+
nextAction: pendingQuestionnaires.mine.length > 0
|
|
2672
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.'
|
|
2673
3365
|
: taskSelectionRequired
|
|
2674
|
-
? '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.'
|
|
2675
3367
|
: entryNextAction(authenticated, state),
|
|
2676
3368
|
});
|
|
2677
3369
|
});
|
|
@@ -2730,7 +3422,7 @@ export class BridgeService {
|
|
|
2730
3422
|
}
|
|
2731
3423
|
async sessionSetDecision(input) {
|
|
2732
3424
|
return await this.execute(async () => {
|
|
2733
|
-
const repository = await this.dependencies.repositories.
|
|
3425
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
2734
3426
|
if (input.decision === 'none') {
|
|
2735
3427
|
await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
|
|
2736
3428
|
return asJsonValue({
|
|
@@ -2873,6 +3565,10 @@ export class BridgeService {
|
|
|
2873
3565
|
}
|
|
2874
3566
|
const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
|
|
2875
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');
|
|
2876
3572
|
const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
|
|
2877
3573
|
if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
|
|
2878
3574
|
throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
|
|
@@ -2994,7 +3690,7 @@ export class BridgeService {
|
|
|
2994
3690
|
throw refuse('The receipt does not belong to this task project.', 'session.resume');
|
|
2995
3691
|
if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
|
|
2996
3692
|
throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
|
|
2997
|
-
await this.checkoutSource(repository, pointer.source);
|
|
3693
|
+
await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
|
|
2998
3694
|
await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
|
|
2999
3695
|
const entries = await this.dependencies.outbox.listForTask(taskId);
|
|
3000
3696
|
if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
|
|
@@ -3108,19 +3804,16 @@ export class BridgeService {
|
|
|
3108
3804
|
});
|
|
3109
3805
|
const backend = objectValue(response.data);
|
|
3110
3806
|
const task = objectValue(backend?.task);
|
|
3111
|
-
if (!task ||
|
|
3112
|
-
task.id !== body.taskId ||
|
|
3113
|
-
task.projectId !== entry.projectId ||
|
|
3114
|
-
backend?.requiresContextRefresh === true)
|
|
3807
|
+
if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
|
|
3115
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');
|
|
3116
3811
|
recovered = Boolean(task &&
|
|
3117
3812
|
task.id === body.taskId &&
|
|
3118
3813
|
task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
|
|
3119
3814
|
task.selfReviewDiffHash === body.diffHash &&
|
|
3120
|
-
stableStringify(asJsonValue(task.
|
|
3121
|
-
stableStringify(asJsonValue(body.
|
|
3122
|
-
stableStringify(asJsonValue(task.selfReviewFindings)) ===
|
|
3123
|
-
stableStringify(asJsonValue(body.findings)));
|
|
3815
|
+
stableStringify(asJsonValue(task.selfReviewFiles)) ===
|
|
3816
|
+
stableStringify(asJsonValue(body.files)));
|
|
3124
3817
|
if (recovered)
|
|
3125
3818
|
await this.captureTaskSnapshot(response.data);
|
|
3126
3819
|
}
|
|
@@ -3163,13 +3856,34 @@ export class BridgeService {
|
|
|
3163
3856
|
}
|
|
3164
3857
|
return pending.length;
|
|
3165
3858
|
}
|
|
3166
|
-
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) {
|
|
3167
3876
|
const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
|
|
3168
3877
|
if (expected &&
|
|
3169
3878
|
(!current ||
|
|
3170
3879
|
current.sourceCommit !== expected.sourceCommit ||
|
|
3171
3880
|
current.treeHash !== expected.treeHash)) {
|
|
3172
|
-
|
|
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');
|
|
3173
3887
|
}
|
|
3174
3888
|
if (current && repository.git.head !== current.sourceCommit) {
|
|
3175
3889
|
throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
|
|
@@ -3195,14 +3909,17 @@ export class BridgeService {
|
|
|
3195
3909
|
body: {
|
|
3196
3910
|
sessionId: pointer.sessionId,
|
|
3197
3911
|
repoFingerprint: repository.repoFingerprint,
|
|
3198
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
3912
|
+
source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
|
|
3199
3913
|
afterSequence: 0,
|
|
3200
3914
|
},
|
|
3201
3915
|
});
|
|
3202
3916
|
const backend = objectValue(response.data);
|
|
3203
3917
|
const task = objectValue(backend?.task);
|
|
3204
|
-
if (!task || task.id !== taskId
|
|
3205
|
-
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');
|
|
3206
3923
|
}
|
|
3207
3924
|
const refreshed = {
|
|
3208
3925
|
...pointer,
|
|
@@ -3224,8 +3941,11 @@ export class BridgeService {
|
|
|
3224
3941
|
});
|
|
3225
3942
|
const backend = objectValue(response.data);
|
|
3226
3943
|
const task = objectValue(backend?.task);
|
|
3227
|
-
if (!task || task.id !== taskId
|
|
3228
|
-
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');
|
|
3229
3949
|
}
|
|
3230
3950
|
const refreshed = {
|
|
3231
3951
|
...pointer,
|
|
@@ -3240,6 +3960,13 @@ export class BridgeService {
|
|
|
3240
3960
|
const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
|
|
3241
3961
|
const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
|
|
3242
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
|
+
}
|
|
3243
3970
|
if (!pointer ||
|
|
3244
3971
|
pointer.taskId !== taskId ||
|
|
3245
3972
|
(blockingConflicts?.length ?? 0) > 0 ||
|
|
@@ -3256,7 +3983,7 @@ export class BridgeService {
|
|
|
3256
3983
|
}
|
|
3257
3984
|
}
|
|
3258
3985
|
async retryVerificationRecovery(repository, pointer, backend) {
|
|
3259
|
-
await this.checkoutSource(repository, pointer.source);
|
|
3986
|
+
await this.checkoutSource(repository, pointer.source, pointer);
|
|
3260
3987
|
const intent = pointer.verificationIntent;
|
|
3261
3988
|
const recovery = objectValue(backend?.verificationRecovery);
|
|
3262
3989
|
if (!intent) {
|
|
@@ -3383,7 +4110,9 @@ export class BridgeService {
|
|
|
3383
4110
|
return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
|
|
3384
4111
|
}
|
|
3385
4112
|
async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
|
|
3386
|
-
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
|
+
});
|
|
3387
4116
|
const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
|
|
3388
4117
|
const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
3389
4118
|
if (typeof body.branch === 'string' && body.branch !== currentBranch)
|
|
@@ -3424,7 +4153,15 @@ export class BridgeService {
|
|
|
3424
4153
|
taskChanges,
|
|
3425
4154
|
});
|
|
3426
4155
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
3427
|
-
const
|
|
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
|
+
});
|
|
4162
|
+
const allocation = repository.projectId
|
|
4163
|
+
? await this.ownedAllocation(repository.projectId, repository.repoRoot)
|
|
4164
|
+
: this.poolAllocations.get(repository.repoRoot);
|
|
3428
4165
|
if (allocation && this.dependencies.worktreePool)
|
|
3429
4166
|
await this.dependencies.worktreePool.delivery(allocation.projectId, repository.repoRoot, allocation.generation);
|
|
3430
4167
|
else
|
|
@@ -3435,7 +4172,7 @@ export class BridgeService {
|
|
|
3435
4172
|
...closedTask,
|
|
3436
4173
|
repository: publicRepository(repository),
|
|
3437
4174
|
recoveredAfterResponseLoss,
|
|
3438
|
-
delivery: deliveryQuestion(touchedContract),
|
|
4175
|
+
delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
|
|
3439
4176
|
});
|
|
3440
4177
|
}
|
|
3441
4178
|
async seedResumeSnapshot(pointer, backendPatch) {
|
|
@@ -3485,7 +4222,14 @@ export class BridgeService {
|
|
|
3485
4222
|
return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
|
|
3486
4223
|
})()
|
|
3487
4224
|
: await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
|
|
3488
|
-
|
|
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) {
|
|
3489
4233
|
throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
|
|
3490
4234
|
}
|
|
3491
4235
|
if (pointer.verificationIntent || pointer.closeIntent) {
|
|
@@ -3510,7 +4254,7 @@ export class BridgeService {
|
|
|
3510
4254
|
try {
|
|
3511
4255
|
const allocation = await this.dependencies.worktreePool?.forTask(taskId);
|
|
3512
4256
|
if (allocation) {
|
|
3513
|
-
const owned = this.
|
|
4257
|
+
const owned = this.adoptOwned(allocation.repoRoot, allocation);
|
|
3514
4258
|
if (!owned || owned.generation !== allocation.generation)
|
|
3515
4259
|
throw refuse('Resume the task in its owned worktree before this operation.', 'session.resume');
|
|
3516
4260
|
await this.dependencies.worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation);
|
|
@@ -3566,6 +4310,7 @@ export class BridgeService {
|
|
|
3566
4310
|
code: error.code ?? error.httpStatus,
|
|
3567
4311
|
retryable: error.retryable,
|
|
3568
4312
|
...(error.recovery ? { recovery: error.recovery } : {}),
|
|
4313
|
+
...(error.envelope?.data ? { details: error.envelope.data } : {}),
|
|
3569
4314
|
},
|
|
3570
4315
|
};
|
|
3571
4316
|
}
|
|
@@ -3576,6 +4321,9 @@ export class BridgeService {
|
|
|
3576
4321
|
message: error instanceof Error ? error.message : 'Bridge operation failed',
|
|
3577
4322
|
retryable: false,
|
|
3578
4323
|
...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
|
|
4324
|
+
...(error instanceof BridgeRecoveryError && error.details !== undefined
|
|
4325
|
+
? { details: error.details }
|
|
4326
|
+
: {}),
|
|
3579
4327
|
},
|
|
3580
4328
|
};
|
|
3581
4329
|
}
|
|
@@ -3593,6 +4341,59 @@ function normalizeChangedPaths(paths) {
|
|
|
3593
4341
|
}
|
|
3594
4342
|
return [...new Set(normalized)].sort();
|
|
3595
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
|
+
};
|
|
3596
4397
|
function pathsContainAll(allowedPaths, actualPaths) {
|
|
3597
4398
|
const allowed = new Set(normalizeChangedPaths(allowedPaths));
|
|
3598
4399
|
return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
|
|
@@ -3673,6 +4474,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
|
|
|
3673
4474
|
});
|
|
3674
4475
|
}));
|
|
3675
4476
|
}
|
|
4477
|
+
function pendingQuestionnairesFields(value) {
|
|
4478
|
+
return {
|
|
4479
|
+
pendingQuestionnaires: value.mine,
|
|
4480
|
+
...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
3676
4483
|
function describePointer(pointer) {
|
|
3677
4484
|
return asJsonValue({
|
|
3678
4485
|
taskSlug: pointer.taskSlug,
|
|
@@ -3719,26 +4526,42 @@ function entryNextAction(authenticated, decision) {
|
|
|
3719
4526
|
return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
|
|
3720
4527
|
}
|
|
3721
4528
|
if (decision === 'bound') {
|
|
3722
|
-
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.';
|
|
3723
4530
|
}
|
|
3724
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.';
|
|
3725
4532
|
}
|
|
3726
|
-
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
|
+
: '');
|
|
3727
4549
|
return asJsonValue({
|
|
3728
4550
|
required: true,
|
|
3729
|
-
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,
|
|
3730
4553
|
options: [
|
|
3731
|
-
{ id: 'commit', label:
|
|
3732
|
-
{ id: 'commit_push', label:
|
|
4554
|
+
{ id: 'commit', label: `Commit on ${onto}` },
|
|
4555
|
+
{ id: 'commit_push', label: `Commit and push${to}${blocked}` },
|
|
3733
4556
|
{
|
|
3734
4557
|
id: 'commit_push_draft_pr',
|
|
3735
|
-
label:
|
|
3736
|
-
input:
|
|
4558
|
+
label: `Commit, push${to} and open a draft pull request${blocked}`,
|
|
4559
|
+
input: base,
|
|
3737
4560
|
},
|
|
3738
4561
|
{
|
|
3739
4562
|
id: 'commit_push_pr',
|
|
3740
|
-
label:
|
|
3741
|
-
input:
|
|
4563
|
+
label: `Commit, push${to} and open a pull request${blocked}`,
|
|
4564
|
+
input: base,
|
|
3742
4565
|
},
|
|
3743
4566
|
],
|
|
3744
4567
|
...(touchedContract
|
|
@@ -3747,6 +4570,9 @@ function deliveryQuestion(touchedContract = false) {
|
|
|
3747
4570
|
}
|
|
3748
4571
|
: {}),
|
|
3749
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.',
|
|
3750
4576
|
});
|
|
3751
4577
|
}
|
|
3752
4578
|
function cleanJson(value) {
|
|
@@ -3769,6 +4595,73 @@ function objectValue(value) {
|
|
|
3769
4595
|
function objectOrEmpty(value) {
|
|
3770
4596
|
return objectValue(value ?? undefined) ?? {};
|
|
3771
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
|
+
}
|
|
3772
4665
|
function archivedProjectGuidance(project) {
|
|
3773
4666
|
const projectId = typeof project?.id === 'string' ? project.id : null;
|
|
3774
4667
|
const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
|
|
@@ -3816,6 +4709,11 @@ function classifyError(error) {
|
|
|
3816
4709
|
}
|
|
3817
4710
|
return 'bridge_delivery_error';
|
|
3818
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
|
+
}
|
|
3819
4717
|
function isDefinitiveAuthenticationFailure(error) {
|
|
3820
4718
|
return (error instanceof ApiResponseError &&
|
|
3821
4719
|
(error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
|
|
@@ -3893,24 +4791,49 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
3893
4791
|
return false;
|
|
3894
4792
|
}
|
|
3895
4793
|
}
|
|
3896
|
-
function
|
|
3897
|
-
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);
|
|
3898
4800
|
}
|
|
3899
4801
|
function short(value) {
|
|
3900
4802
|
return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
|
|
3901
4803
|
}
|
|
3902
|
-
function
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
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)}`;
|
|
3914
4837
|
}
|
|
3915
4838
|
function repairChangeBaseline(local, recordedDiffHash, head) {
|
|
3916
4839
|
if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
|
|
@@ -4101,7 +5024,7 @@ function globMatches(path, pattern) {
|
|
|
4101
5024
|
}
|
|
4102
5025
|
return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
|
|
4103
5026
|
}
|
|
4104
|
-
function
|
|
5027
|
+
function newResourceEvidenceFindings(candidates, evidence) {
|
|
4105
5028
|
const normalizedEvidence = evidence.map((entry) => ({
|
|
4106
5029
|
...entry,
|
|
4107
5030
|
path: normalizeChangedPaths([entry.path])[0],
|
|
@@ -4112,47 +5035,56 @@ function assertNewResourceEvidence(candidates, evidence) {
|
|
|
4112
5035
|
if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
|
|
4113
5036
|
throw refuse('New resource evidence contains duplicate paths', 'task.verify');
|
|
4114
5037
|
}
|
|
4115
|
-
const
|
|
4116
|
-
const
|
|
4117
|
-
|
|
4118
|
-
.
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
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
|
+
: [];
|
|
4134
5064
|
}
|
|
4135
|
-
function
|
|
5065
|
+
function validationEvidenceFindings(validations) {
|
|
4136
5066
|
if (validations.length === 0) {
|
|
4137
|
-
|
|
5067
|
+
return ['Validation evidence is required'];
|
|
4138
5068
|
}
|
|
4139
5069
|
const allowed = new Set(validationIds);
|
|
4140
5070
|
const seen = new Set();
|
|
5071
|
+
const findings = [];
|
|
4141
5072
|
for (const validation of validations) {
|
|
4142
5073
|
if (!allowed.has(validation.validationId)) {
|
|
4143
|
-
|
|
5074
|
+
findings.push(`Unknown validation evidence: ${validation.validationId}`);
|
|
4144
5075
|
}
|
|
4145
|
-
if (seen.has(validation.validationId)) {
|
|
4146
|
-
|
|
5076
|
+
else if (seen.has(validation.validationId)) {
|
|
5077
|
+
findings.push(`Duplicate validation evidence: ${validation.validationId}`);
|
|
4147
5078
|
}
|
|
4148
5079
|
if (!validation.command.trim()) {
|
|
4149
|
-
|
|
5080
|
+
findings.push(`Validation command is required: ${validation.validationId}`);
|
|
4150
5081
|
}
|
|
4151
5082
|
if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
|
|
4152
|
-
|
|
5083
|
+
findings.push(`Validation output hash is invalid: ${validation.validationId}`);
|
|
4153
5084
|
}
|
|
4154
5085
|
seen.add(validation.validationId);
|
|
4155
5086
|
}
|
|
5087
|
+
return findings;
|
|
4156
5088
|
}
|
|
4157
5089
|
function sourceFromContext(value) {
|
|
4158
5090
|
const context = objectValue(value);
|