@zq-silk/yui 0.11.3 → 0.12.0
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/ARCHITECTURE.md +23 -4
- package/README.md +36 -13
- package/dist/cli/commandCatalog.js +2 -4
- package/dist/cli.js +105 -43
- package/dist/commands/configCommands.js +12 -45
- package/dist/commands/executionAuditCommands.js +1 -1
- package/dist/commands/jobCommands.js +4 -10
- package/dist/commands/taskCommands.js +154 -171
- package/dist/commands/taskCompletionGate.js +29 -54
- package/dist/commands/taskContextCommand.js +55 -11
- package/dist/commands/taskInputCommands.js +8 -5
- package/dist/commands/taskNextActionCommand.js +56 -6
- package/dist/commands/taskOverviewCommand.js +17 -31
- package/dist/commands/taskRoleRuntimeStatus.js +24 -0
- package/dist/commands/taskWorkspaceCommands.js +5 -2
- package/dist/config/configCatalog.js +2 -2
- package/dist/config/yuiConfig.js +3 -7
- package/dist/context/wakeNotification.js +20 -5
- package/dist/controller/agentRuntimeObserver.js +247 -51
- package/dist/controller/clientRuntime.js +1 -1
- package/dist/controller/controller.js +8 -12
- package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
- package/dist/controller/runtime.js +3 -3
- package/dist/controller/runtimeEventInbox.js +17 -7
- package/dist/controller/runtimeEventProcessor.js +12 -19
- package/dist/controller/runtimeObservationHook.js +45 -0
- package/dist/execution/resourceBroker.js +5 -4
- package/dist/executor/agentExecutor.js +4 -4
- package/dist/executor/effectiveLaunch.js +8 -48
- package/dist/executor/fileRoleLaunchPlanner.js +36 -16
- package/dist/executor/workspacePreflightClassification.js +23 -2
- package/dist/interaction/operatorPresentation.js +33 -89
- package/dist/lifecycle/exactRunTerminalization.js +1 -2
- package/dist/lifecycle/taskRoleSessionReset.js +5 -13
- package/dist/observability/orchestrationMetrics.js +8 -3
- package/dist/profile/agentProfile.js +1 -1
- package/dist/release/cliHomeReleaseFence.js +123 -0
- package/dist/repository/taskWorkspacePreparer.js +176 -60
- package/dist/resources/sqliteResourceRegistry.js +1 -1
- package/dist/review/deltaRecheck.js +12 -51
- package/dist/review/reviewAcceptance.js +26 -0
- package/dist/review/reviewConfig.js +0 -31
- package/dist/review/reviewDecision.js +113 -0
- package/dist/review/reviewOutcomeClassifier.js +1 -1
- package/dist/review/reviewRound.js +1 -1
- package/dist/review/reviewerAvailability.js +69 -0
- package/dist/runtime/agentDriverObservation.js +24 -10
- package/dist/runtime/builtinAgentDrivers.js +4 -3
- package/dist/runtime/builtinTranscriptObserver.js +301 -64
- package/dist/runtime/builtinTranscriptUsage.js +9 -7
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runtimeObservation.js +13 -0
- package/dist/runtime/runtimeProjection.js +16 -25
- package/dist/runtime/sessionTokenMetrics.js +181 -0
- package/dist/runtime/structuredProviderHost.js +7 -1
- package/dist/scheduler/activeRoleRunDelivery.js +4 -4
- package/dist/scheduler/activeTaskProgress.js +7 -6
- package/dist/scheduler/leaderWakeupProcessor.js +3 -5
- package/dist/scheduler/operatorEvent.js +34 -0
- package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
- package/dist/scheduler/roleRunStall.js +0 -14
- package/dist/scheduler/taskExecutionProjection.js +97 -76
- package/dist/scheduler/taskObservabilityProjection.js +10 -11
- package/dist/storage/migration/productionRegistry.js +379 -0
- package/dist/storage/sqliteSchema.js +18 -8
- package/dist/storage/sqliteStore.js +24 -20
- package/dist/storage/storeRpc.js +0 -1
- package/dist/storage/taskStore.js +15 -30
- package/dist/storage/upgrade/recordVersions.js +3 -2
- package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
- package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
- package/dist/task/completionReadiness.js +10 -6
- package/dist/task/nextAction.js +82 -24
- package/dist/telemetry/sqliteTelemetryStore.js +1 -1
- package/dist/web/assets/client/components.js +13 -3
- package/dist/web/assets/client/i18n.js +6 -2
- package/dist/web/webSnapshot.js +4 -0
- package/i18n/README.zh-CN.md +2 -2
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +53 -16
- package/skills/yui-operator/SKILL.md +9 -0
- package/skills/yui-reviewer/SKILL.md +28 -7
- package/dist/context/sessionContextBudget.js +0 -71
- package/dist/lifecycle/contextBudgetRollover.js +0 -81
- package/dist/scheduler/operatorNotification.js +0 -59
|
@@ -2,13 +2,16 @@ import { lstat, mkdir, readlink, readdir, rmdir, symlink, unlink } from "node:fs
|
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
4
|
import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
|
|
5
|
+
import { formatWorkspacePreflightError } from "../executor/workspacePreflightClassification.js";
|
|
5
6
|
import { updateRole } from "../role/role.js";
|
|
6
7
|
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
7
8
|
import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
|
|
8
9
|
import { StorageConflictError } from "../storage/taskStore.js";
|
|
9
|
-
import { bindTaskWorkspaceIdentity } from "../task/task.js";
|
|
10
|
+
import { activateTask, bindTaskWorkspaceIdentity } from "../task/task.js";
|
|
11
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
12
|
+
import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
10
13
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
|
|
11
|
-
import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
|
|
14
|
+
import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
|
|
12
15
|
import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
|
|
13
16
|
import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
14
17
|
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
@@ -80,7 +83,47 @@ export class FileTaskWorkspacePreparer {
|
|
|
80
83
|
const task = requireTask(this.store, taskId);
|
|
81
84
|
const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
82
85
|
try {
|
|
83
|
-
return await this.#prepareTaskWorkspaceLocked(current.id);
|
|
86
|
+
return await this.#prepareTaskWorkspaceLocked(current.id, false);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
release();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof StorageConflictError
|
|
94
|
+
&& attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Prepare every physical Task-main resource and adopt it in the same store
|
|
103
|
+
* transaction that moves the Task from Draft to active. A Draft never owns a
|
|
104
|
+
* durable writable workspace before this boundary, and any failed attempt
|
|
105
|
+
* discards its unadopted refs/worktrees.
|
|
106
|
+
*/
|
|
107
|
+
async activateTaskWorkspace(taskId) {
|
|
108
|
+
for (let attempt = 0;; attempt += 1) {
|
|
109
|
+
try {
|
|
110
|
+
const task = requireTask(this.store, taskId);
|
|
111
|
+
if (task.status === "active") {
|
|
112
|
+
const workspace = this.store.getTaskWorkspace(task.id);
|
|
113
|
+
if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
|
|
114
|
+
throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
task,
|
|
118
|
+
taskId: task.id,
|
|
119
|
+
status: "ready",
|
|
120
|
+
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
121
|
+
changed: false
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
|
|
125
|
+
try {
|
|
126
|
+
return await this.#prepareTaskWorkspaceLocked(current.id, true);
|
|
84
127
|
}
|
|
85
128
|
finally {
|
|
86
129
|
release();
|
|
@@ -137,59 +180,94 @@ export class FileTaskWorkspacePreparer {
|
|
|
137
180
|
acquireTaskProjectMaintenanceLocks(taskId) {
|
|
138
181
|
return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
|
|
139
182
|
}
|
|
140
|
-
async #prepareTaskWorkspaceLocked(taskId) {
|
|
183
|
+
async #prepareTaskWorkspaceLocked(taskId, activate) {
|
|
141
184
|
const task = requireTask(this.store, taskId);
|
|
142
|
-
if (
|
|
185
|
+
if (activate && task.status !== "draft") {
|
|
186
|
+
throw new Error(`Only a Draft Task can atomically adopt a workspace: ${task.id}/${task.status}.`);
|
|
187
|
+
}
|
|
188
|
+
if (!activate && task.status === "draft") {
|
|
189
|
+
throw new Error(`Draft Task workspace must be adopted by task activation: ${task.id}.`);
|
|
190
|
+
}
|
|
191
|
+
if (!activate && task.status !== "active") {
|
|
143
192
|
throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
|
|
144
193
|
}
|
|
194
|
+
const existing = this.store.getTaskWorkspace(task.id);
|
|
195
|
+
if (activate && (task.workspaceIdentity !== undefined
|
|
196
|
+
|| task.cwd !== undefined
|
|
197
|
+
|| existing !== null)) {
|
|
198
|
+
throw new Error(`Draft Task already has adopted workspace state: ${task.id}. `
|
|
199
|
+
+ "Inspect it with `yui task base status`, then retire and recreate the Draft.");
|
|
200
|
+
}
|
|
201
|
+
if (!activate && existing === null) {
|
|
202
|
+
throw new Error(`Active Task has no durable workspace owner: ${task.id}. `
|
|
203
|
+
+ "Use `yui task base status` to diagnose the incomplete activation.");
|
|
204
|
+
}
|
|
145
205
|
if (task.projectBindings.length === 0) {
|
|
146
|
-
const existing = this.store.getTaskWorkspace(task.id);
|
|
147
206
|
const root = this.#taskWorkspaceRoot(task.id);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const current = tx.getTaskWorkspace(task.id);
|
|
171
|
-
// `createManagedWorkspace` stamps a fresh updatedAt on every call.
|
|
172
|
-
// Gitless preparation is idempotent, so compare only stable identity
|
|
173
|
-
// and retain the existing durable record verbatim.
|
|
174
|
-
if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
|
|
175
|
-
throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
|
|
176
|
-
}
|
|
177
|
-
const timestamp = this.now();
|
|
178
|
-
if (latest.cwd !== root) {
|
|
179
|
-
tx.saveTask({ ...latest, cwd: root, updatedAt: timestamp.toISOString() });
|
|
180
|
-
}
|
|
181
|
-
if (current === null)
|
|
182
|
-
tx.saveManagedWorkspace(workspace);
|
|
183
|
-
for (const role of tx.listRoles(task.id)) {
|
|
184
|
-
if (role.workspace !== root) {
|
|
185
|
-
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
186
|
-
tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
|
|
207
|
+
try {
|
|
208
|
+
if (existing !== null && (existing.owner.type !== "task"
|
|
209
|
+
|| existing.owner.taskId !== task.id
|
|
210
|
+
|| existing.root !== root
|
|
211
|
+
|| existing.entries.length !== 0)) {
|
|
212
|
+
throw new Error(`Gitless Task workspace ownership is invalid: ${task.id}.`);
|
|
213
|
+
}
|
|
214
|
+
// Gitless Tasks still need a durable runtime owner. The empty view is
|
|
215
|
+
// Task-specific, so launches cannot fall back to a global workspace or
|
|
216
|
+
// repository root while no Project is bound.
|
|
217
|
+
await ensureWorkspaceView(root, []);
|
|
218
|
+
const workspace = createManagedWorkspace({
|
|
219
|
+
owner: { type: "task", taskId: task.id },
|
|
220
|
+
root,
|
|
221
|
+
entries: []
|
|
222
|
+
}, this.now());
|
|
223
|
+
this.#registerWorkspace(workspace);
|
|
224
|
+
const persisted = this.store.transaction((tx) => {
|
|
225
|
+
const latest = requireTask(tx, task.id);
|
|
226
|
+
if (latest.status !== task.status
|
|
227
|
+
|| latest.projectBindings.length !== 0) {
|
|
228
|
+
throw new Error(`Task changed while preparing its Gitless workspace: ${task.id}.`);
|
|
187
229
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
230
|
+
const current = tx.getTaskWorkspace(task.id);
|
|
231
|
+
// `createManagedWorkspace` stamps a fresh updatedAt on every call.
|
|
232
|
+
// Gitless preparation is idempotent, so compare only stable identity
|
|
233
|
+
// and retain the existing durable record verbatim.
|
|
234
|
+
if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
|
|
235
|
+
throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
|
|
236
|
+
}
|
|
237
|
+
const timestamp = this.now();
|
|
238
|
+
let next = latest.cwd === root
|
|
239
|
+
? latest
|
|
240
|
+
: { ...latest, cwd: root, updatedAt: timestamp.toISOString() };
|
|
241
|
+
if (activate)
|
|
242
|
+
next = activateTask(next, timestamp);
|
|
243
|
+
if (!isDeepStrictEqual(next, latest))
|
|
244
|
+
tx.saveTask(next);
|
|
245
|
+
if (current === null)
|
|
246
|
+
tx.saveManagedWorkspace(workspace);
|
|
247
|
+
for (const role of tx.listRoles(task.id)) {
|
|
248
|
+
if (role.workspace !== root) {
|
|
249
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
250
|
+
tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (activate)
|
|
254
|
+
recordTaskActivation(tx, latest, next, timestamp);
|
|
255
|
+
return next;
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
task: persisted,
|
|
259
|
+
taskId,
|
|
260
|
+
status: "ready",
|
|
261
|
+
path: root,
|
|
262
|
+
changed: activate
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (activate)
|
|
267
|
+
await removeWorkspaceView(root);
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
191
270
|
}
|
|
192
|
-
const existing = this.store.getTaskWorkspace(task.id);
|
|
193
271
|
if (existing !== null && existing.owner.type !== "task") {
|
|
194
272
|
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
195
273
|
}
|
|
@@ -268,6 +346,18 @@ export class FileTaskWorkspacePreparer {
|
|
|
268
346
|
roleName: MAIN_WORKTREE,
|
|
269
347
|
baseRef: previous?.baseCommit ?? baseline.baseRef
|
|
270
348
|
});
|
|
349
|
+
if (previous !== undefined
|
|
350
|
+
&& !await this.git.isAncestor(physical.path, previous.baseCommit, physical.baseCommit)) {
|
|
351
|
+
throw new Error(formatWorkspacePreflightError({
|
|
352
|
+
kind: "physical-drift",
|
|
353
|
+
reason: `Task main physical HEAD left its recorded lineage: ${task.id}/${project.id}.`,
|
|
354
|
+
taskId: task.id,
|
|
355
|
+
roleName: LEADER_ROLE,
|
|
356
|
+
projectId: project.id,
|
|
357
|
+
expectedCommit: previous.baseCommit,
|
|
358
|
+
physicalCommit: physical.baseCommit
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
271
361
|
if (baseline.expectedCommit !== undefined
|
|
272
362
|
&& physical.baseCommit !== baseline.expectedCommit) {
|
|
273
363
|
throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
|
|
@@ -289,7 +379,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
289
379
|
path: physical.path,
|
|
290
380
|
branch: physical.branch,
|
|
291
381
|
baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
|
|
292
|
-
baseCommit: physical.baseCommit
|
|
382
|
+
baseCommit: previous?.baseCommit ?? physical.baseCommit
|
|
293
383
|
},
|
|
294
384
|
provenance
|
|
295
385
|
});
|
|
@@ -301,9 +391,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
301
391
|
entries: prepared.map(({ entry }) => entry)
|
|
302
392
|
}, this.now());
|
|
303
393
|
this.#registerWorkspace(workspace);
|
|
304
|
-
this.store.transaction((tx) => {
|
|
394
|
+
const persisted = this.store.transaction((tx) => {
|
|
305
395
|
const latest = requireTask(tx, task.id);
|
|
306
|
-
if (
|
|
396
|
+
if (latest.status !== task.status) {
|
|
307
397
|
throw new Error(`Task changed while preparing its workspace: ${task.id}.`);
|
|
308
398
|
}
|
|
309
399
|
if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
|
|
@@ -358,6 +448,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
358
448
|
if (persistedTask.cwd !== root) {
|
|
359
449
|
persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
|
|
360
450
|
}
|
|
451
|
+
if (activate)
|
|
452
|
+
persistedTask = activateTask(persistedTask, timestamp);
|
|
361
453
|
if (!isDeepStrictEqual(persistedTask, latest)) {
|
|
362
454
|
tx.saveTask(persistedTask);
|
|
363
455
|
}
|
|
@@ -366,9 +458,10 @@ export class FileTaskWorkspacePreparer {
|
|
|
366
458
|
recordTaskBaseProvenanceEvents(tx, task.id, prepared.map(({ provenance }) => provenance), timestamp);
|
|
367
459
|
}
|
|
368
460
|
for (const role of tx.listRoles(task.id)) {
|
|
369
|
-
// The Role field is only a cwd/snapshot hint.
|
|
370
|
-
//
|
|
371
|
-
// not
|
|
461
|
+
// The Role field is only a cwd/snapshot hint. Preserve it while a
|
|
462
|
+
// durable WorkItem or ReviewRound workspace owns that cwd; Task-main
|
|
463
|
+
// preparation must not move a Reviewer Session out from under an
|
|
464
|
+
// active or retained ReviewRound. Other Roles use Task main.
|
|
372
465
|
// Prefer the active Run's exact WorkItem for this Role; fall back
|
|
373
466
|
// to the first queued WorkItem only when no active Run owns the Role.
|
|
374
467
|
const activeRoleRun = tx.getActiveAgentRun(task.id, role.name);
|
|
@@ -387,7 +480,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
387
480
|
const assignedWorkspace = assignedItem === undefined
|
|
388
481
|
? null
|
|
389
482
|
: tx.getWorkItemWorkspace(task.id, assignedItem.id);
|
|
390
|
-
const
|
|
483
|
+
const reviewWorkspace = tx.listManagedWorkspaces(task.id).find((candidate) => {
|
|
484
|
+
if (candidate.owner.type !== "review-round"
|
|
485
|
+
|| candidate.root !== role.workspace)
|
|
486
|
+
return false;
|
|
487
|
+
return tx.getReviewRound(task.id, candidate.owner.reviewRoundId)
|
|
488
|
+
?.reviewerRoleName === role.name;
|
|
489
|
+
});
|
|
490
|
+
const target = assignedWorkspace?.root ?? reviewWorkspace?.root ?? root;
|
|
391
491
|
if (role.workspace !== target) {
|
|
392
492
|
if (assignedItem === undefined
|
|
393
493
|
|| assignedWorkspace === null
|
|
@@ -397,14 +497,25 @@ export class FileTaskWorkspacePreparer {
|
|
|
397
497
|
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
398
498
|
}
|
|
399
499
|
}
|
|
500
|
+
if (activate)
|
|
501
|
+
recordTaskActivation(tx, latest, persistedTask, timestamp);
|
|
502
|
+
return persistedTask;
|
|
400
503
|
});
|
|
401
|
-
return {
|
|
504
|
+
return {
|
|
505
|
+
task: persisted,
|
|
506
|
+
taskId,
|
|
507
|
+
status: "ready",
|
|
508
|
+
path: root,
|
|
509
|
+
changed: activate
|
|
510
|
+
};
|
|
402
511
|
}
|
|
403
512
|
catch (error) {
|
|
404
513
|
// A failed or conflicted preparation owns no durable record: drop the
|
|
405
514
|
// branches too, so a retry mints a clean identity without half-created
|
|
406
515
|
// refs behind. Adopted (already catalogued) worktrees are never touched.
|
|
407
516
|
await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
|
|
517
|
+
if (activate)
|
|
518
|
+
await removeWorkspaceView(root);
|
|
408
519
|
throw error;
|
|
409
520
|
}
|
|
410
521
|
}
|
|
@@ -418,7 +529,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
418
529
|
async #prepareTaskWorkspaceWithRetries(taskId, lockedProjectIds) {
|
|
419
530
|
for (let attempt = 0;; attempt += 1) {
|
|
420
531
|
try {
|
|
421
|
-
return await this.#prepareTaskWorkspaceLocked(taskId);
|
|
532
|
+
return await this.#prepareTaskWorkspaceLocked(taskId, false);
|
|
422
533
|
}
|
|
423
534
|
catch (error) {
|
|
424
535
|
if (!(error instanceof StorageConflictError)
|
|
@@ -1869,8 +1980,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
1869
1980
|
*/
|
|
1870
1981
|
async rebuildTaskWorkspace(taskId, options = {}) {
|
|
1871
1982
|
const task = requireTask(this.store, taskId);
|
|
1872
|
-
if (
|
|
1873
|
-
throw new Error(`Only
|
|
1983
|
+
if (task.status !== "active") {
|
|
1984
|
+
throw new Error(`Only an active Task can rebuild its adopted workspace: ${task.id}/${task.status}.`);
|
|
1874
1985
|
}
|
|
1875
1986
|
// The rebuild holds every touched Project's maintenance fence for its
|
|
1876
1987
|
// whole duration, so the Controller defers preparation and no other
|
|
@@ -2521,6 +2632,11 @@ export class FileTaskWorkspacePreparer {
|
|
|
2521
2632
|
});
|
|
2522
2633
|
}
|
|
2523
2634
|
}
|
|
2635
|
+
function recordTaskActivation(store, previous, active, now) {
|
|
2636
|
+
enqueueWork(store, { kind: "role", taskId: active.id, roleName: LEADER_ROLE }, "task-created", now, [{ type: "task", id: active.id }]);
|
|
2637
|
+
enqueueWork(store, { kind: "task", taskId: active.id }, "task-activated", now, [{ type: "task", id: active.id }]);
|
|
2638
|
+
store.saveEvent(active.id, createTaskEvent(store.nextEventId(active.id), active.id, "task.activated", { fromStatus: previous.status, status: active.status }, now));
|
|
2639
|
+
}
|
|
2524
2640
|
function requireTask(store, taskId) {
|
|
2525
2641
|
const task = store.getTask(taskId);
|
|
2526
2642
|
if (task === null)
|
|
@@ -34,7 +34,7 @@ export class SqliteResourceRegistry {
|
|
|
34
34
|
this.#db.pragma("journal_mode = WAL");
|
|
35
35
|
this.#db.pragma("foreign_keys = ON");
|
|
36
36
|
this.#db.pragma("busy_timeout = 5000");
|
|
37
|
-
migrateSqliteSchema(this.#db);
|
|
37
|
+
migrateSqliteSchema(this.#db, { mode: "validate" });
|
|
38
38
|
}
|
|
39
39
|
load() {
|
|
40
40
|
const rows = this.#db.prepare(`SELECT payload FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { deltaRecheckMaxChangedFiles, deltaRecheckMaxChangedLines } from "./reviewConfig.js";
|
|
3
2
|
import { validateDeltaRecheckRecord } from "./reviewRound.js";
|
|
4
3
|
import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
|
|
5
4
|
/**
|
|
6
|
-
* Assesses whether a delta
|
|
7
|
-
*
|
|
8
|
-
* Reviewer's explicit disposition.
|
|
5
|
+
* Assesses whether a trustworthy delta can be constructed. Only technical
|
|
6
|
+
* unavailability fails closed; Agents retain the semantic judgment.
|
|
9
7
|
*/
|
|
10
8
|
export async function assessDeltaRecheck(input) {
|
|
11
|
-
const { repositoryPaths, previousRound, candidate, git
|
|
9
|
+
const { repositoryPaths, previousRound, candidate, git } = input;
|
|
12
10
|
if (!isSemanticReviewRound(previousRound)
|
|
13
11
|
|| (previousRound.scope ?? "work-item") !== "task") {
|
|
14
12
|
return {
|
|
@@ -23,6 +21,12 @@ export async function assessDeltaRecheck(input) {
|
|
|
23
21
|
};
|
|
24
22
|
}
|
|
25
23
|
const previousByProject = new Map(previousRound.taskCandidate.projects.map((project) => [project.projectId, project.commit]));
|
|
24
|
+
if (previousByProject.size !== candidate.projects.length) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "ineligible",
|
|
27
|
+
reason: "Delta recheck Project scope differs from the accepted baseline."
|
|
28
|
+
};
|
|
29
|
+
}
|
|
26
30
|
const diffByProject = {};
|
|
27
31
|
const changedFiles = [];
|
|
28
32
|
let addedLines = 0;
|
|
@@ -91,31 +95,6 @@ export async function assessDeltaRecheck(input) {
|
|
|
91
95
|
reason: "Frozen heads are unchanged; the previous acceptance already covers this candidate."
|
|
92
96
|
};
|
|
93
97
|
}
|
|
94
|
-
const maxFiles = deltaRecheckMaxChangedFiles(config);
|
|
95
|
-
if (changedFiles.length > maxFiles) {
|
|
96
|
-
return {
|
|
97
|
-
kind: "ineligible",
|
|
98
|
-
reason: `Delta recheck attempt threshold exceeded: ${changedFiles.length} changed file(s) > ${maxFiles}.`
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
const maxLines = deltaRecheckMaxChangedLines(config);
|
|
102
|
-
if (addedLines + deletedLines > maxLines) {
|
|
103
|
-
return {
|
|
104
|
-
kind: "ineligible",
|
|
105
|
-
reason: `Delta recheck attempt threshold exceeded: ${addedLines + deletedLines} changed line(s) > ${maxLines}.`
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
// Evidence overlap: a diff touching a file the previous Round cited as
|
|
109
|
-
// evidence cannot be proven equivalent by a delta recheck.
|
|
110
|
-
const evidencePaths = extractEvidencePaths(previousRound);
|
|
111
|
-
const overlapping = changedFiles.filter((file) => (evidencePaths.some((evidence) => pathEvidenceMatches(evidence, file))));
|
|
112
|
-
if (overlapping.length > 0) {
|
|
113
|
-
return {
|
|
114
|
-
kind: "ineligible",
|
|
115
|
-
reason: "Delta recheck cannot prove evidence validity for changed evidence file(s): "
|
|
116
|
-
+ `${overlapping.join(", ")}.`
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
98
|
const record = validateDeltaRecheckRecord({
|
|
120
99
|
schemaVersion: 1,
|
|
121
100
|
previousReviewRoundId: previousRound.id,
|
|
@@ -148,7 +127,7 @@ export function buildDeltaRecheckDispatchContext(input) {
|
|
|
148
127
|
"- finding: the diff introduces a material problem; report it as a finding.",
|
|
149
128
|
"- requires-full-review: you cannot prove equivalence (uncertainty, cross-scope,",
|
|
150
129
|
" semantic change, evidence doubt). This is the safe default.",
|
|
151
|
-
"
|
|
130
|
+
"Yui verified only the technical delta boundary; the Leader selected this mode.",
|
|
152
131
|
`Previous accepted ReviewRound: ${previousRound.id}@${record.previousBaseCommit}`,
|
|
153
132
|
`Previous acceptance summary: ${compact(previousRound.summary ?? previousRound.report ?? "")}`,
|
|
154
133
|
...(round.taskCandidate?.projects.map((project) => {
|
|
@@ -187,15 +166,6 @@ function digestDiff(diffByProject) {
|
|
|
187
166
|
.map(([projectId, diff]) => [projectId, diff]));
|
|
188
167
|
return createHash("sha256").update(canonical).digest("hex");
|
|
189
168
|
}
|
|
190
|
-
/**
|
|
191
|
-
* Extracts path-like evidence references from the previous Round's report.
|
|
192
|
-
* Free-form evidence that is not a path is still passed to the Reviewer in
|
|
193
|
-
* the dispatch context; only path-like entries drive the deterministic gate.
|
|
194
|
-
*/
|
|
195
|
-
function extractEvidencePaths(round) {
|
|
196
|
-
return previousEvidenceReferences(round)
|
|
197
|
-
.filter((entry) => isPathLike(entry));
|
|
198
|
-
}
|
|
199
169
|
/** All path-like evidence references from Markdown or JSON Review reports. */
|
|
200
170
|
function previousEvidenceReferences(round) {
|
|
201
171
|
const report = round.report ?? "";
|
|
@@ -222,9 +192,8 @@ function extractDeclaredEvidence(report) {
|
|
|
222
192
|
}
|
|
223
193
|
/**
|
|
224
194
|
* Reviewer reports intentionally accept clear Markdown or JSON without a
|
|
225
|
-
* fixed schema. Extract
|
|
226
|
-
*
|
|
227
|
-
* format, nested JSON, Markdown links, backticks, or line-qualified paths.
|
|
195
|
+
* fixed schema. Extract likely repo-relative references from the preserved
|
|
196
|
+
* report so the next Reviewer can inspect the earlier evidence directly.
|
|
228
197
|
*/
|
|
229
198
|
function extractEvidenceReferences(report) {
|
|
230
199
|
const references = report.match(/(?:\.{0,2}\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12}/gu) ?? [];
|
|
@@ -244,11 +213,3 @@ function isPathLike(value) {
|
|
|
244
213
|
return false;
|
|
245
214
|
return value.includes("/") || /\.[A-Za-z0-9]{1,12}$/u.test(value);
|
|
246
215
|
}
|
|
247
|
-
function pathEvidenceMatches(evidencePath, changedFile) {
|
|
248
|
-
const normalizedEvidence = evidencePath.replace(/^\.\//u, "").replace(/\/+$/u, "");
|
|
249
|
-
const normalizedFile = changedFile.replace(/^\.\//u, "").replace(/\/+$/u, "");
|
|
250
|
-
if (normalizedEvidence === normalizedFile)
|
|
251
|
-
return true;
|
|
252
|
-
// A directory-level evidence reference covers every file under it.
|
|
253
|
-
return normalizedFile.startsWith(`${normalizedEvidence}/`);
|
|
254
|
-
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a completed Task-final Round is a trustworthy accepted baseline.
|
|
4
|
+
* Semantic completion alone is insufficient when it returned a non-accepting
|
|
5
|
+
* delta disposition or still owns an open material finding.
|
|
6
|
+
*/
|
|
7
|
+
export function isAcceptedTaskReviewBaseline(store, round) {
|
|
8
|
+
return isAcceptedTaskReviewBaselineFromEvidence(round, store);
|
|
9
|
+
}
|
|
10
|
+
/** Pure evidence form shared by read-only projections and mutation paths. */
|
|
11
|
+
export function isAcceptedTaskReviewBaselineFromEvidence(round, evidence) {
|
|
12
|
+
if ((round.scope ?? "work-item") !== "task"
|
|
13
|
+
|| round.status !== "completed"
|
|
14
|
+
|| round.taskCandidate === undefined
|
|
15
|
+
|| round.taskCandidate.projects.length === 0
|
|
16
|
+
|| !isSemanticReviewRound(round, evidence))
|
|
17
|
+
return false;
|
|
18
|
+
if ((round.checks ?? []).some(({ outcome }) => outcome === "failed"))
|
|
19
|
+
return false;
|
|
20
|
+
if (round.deltaRecheck !== undefined
|
|
21
|
+
&& round.deltaRecheck.disposition !== "equivalent-and-accepted")
|
|
22
|
+
return false;
|
|
23
|
+
return !(evidence?.listReviewFindings(round.taskId) ?? []).some((finding) => (finding.lastReviewRoundId === round.id
|
|
24
|
+
&& (finding.severity === "p1" || finding.severity === "p2")
|
|
25
|
+
&& (finding.disposition === "open" || finding.disposition === "fixed-pending-review")));
|
|
26
|
+
}
|
|
@@ -6,11 +6,6 @@ export const REVIEW_TRIGGERS = ["always", "leader", "final"];
|
|
|
6
6
|
* Task completion closed on undispositioned open P1/P2 findings.
|
|
7
7
|
*/
|
|
8
8
|
export const REVIEW_FINDING_LEDGER_MODES = ["shadow", "enforce"];
|
|
9
|
-
/** Issue 07: delta-recheck is opt-in per Project review policy. */
|
|
10
|
-
export const REVIEW_DELTA_RECHECK_MODES = ["enabled", "disabled"];
|
|
11
|
-
/** Issue 07: conservative defaults for whether a delta attempt is allowed. */
|
|
12
|
-
export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES = 200;
|
|
13
|
-
export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES = 5;
|
|
14
9
|
export function validateReviewConfig(config) {
|
|
15
10
|
requireIdentity(config.roleName, "Review Role");
|
|
16
11
|
if (!REVIEW_TRIGGERS.includes(config.trigger)) {
|
|
@@ -20,31 +15,5 @@ export function validateReviewConfig(config) {
|
|
|
20
15
|
&& !REVIEW_FINDING_LEDGER_MODES.includes(config.findingLedger)) {
|
|
21
16
|
throw new Error(`Review finding ledger mode is invalid: ${String(config.findingLedger)}.`);
|
|
22
17
|
}
|
|
23
|
-
if (config.deltaRecheck !== undefined
|
|
24
|
-
&& !REVIEW_DELTA_RECHECK_MODES.includes(config.deltaRecheck)) {
|
|
25
|
-
throw new Error(`Review delta-recheck mode is invalid: ${String(config.deltaRecheck)}.`);
|
|
26
|
-
}
|
|
27
|
-
if (config.deltaRecheckMaxChangedLines !== undefined
|
|
28
|
-
&& (!Number.isInteger(config.deltaRecheckMaxChangedLines)
|
|
29
|
-
|| config.deltaRecheckMaxChangedLines < 1)) {
|
|
30
|
-
throw new Error("Review delta-recheck max changed lines must be a positive integer.");
|
|
31
|
-
}
|
|
32
|
-
if (config.deltaRecheckMaxChangedFiles !== undefined
|
|
33
|
-
&& (!Number.isInteger(config.deltaRecheckMaxChangedFiles)
|
|
34
|
-
|| config.deltaRecheckMaxChangedFiles < 1)) {
|
|
35
|
-
throw new Error("Review delta-recheck max changed files must be a positive integer.");
|
|
36
|
-
}
|
|
37
18
|
return config;
|
|
38
19
|
}
|
|
39
|
-
/** Issue 07: resolves the delta-recheck policy; absent config defaults to disabled. */
|
|
40
|
-
export function deltaRecheckEnabled(config) {
|
|
41
|
-
return config?.deltaRecheck === "enabled";
|
|
42
|
-
}
|
|
43
|
-
/** Issue 07: resolves the line threshold with its conservative default. */
|
|
44
|
-
export function deltaRecheckMaxChangedLines(config) {
|
|
45
|
-
return config?.deltaRecheckMaxChangedLines ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES;
|
|
46
|
-
}
|
|
47
|
-
/** Issue 07: resolves the file threshold with its conservative default. */
|
|
48
|
-
export function deltaRecheckMaxChangedFiles(config) {
|
|
49
|
-
return config?.deltaRecheckMaxChangedFiles ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES;
|
|
50
|
-
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { isAcceptedTaskReviewBaseline } from "./reviewAcceptance.js";
|
|
3
|
+
import { projectReviewerAvailability } from "./reviewerAvailability.js";
|
|
4
|
+
/** Read-only facts for Leader judgment; this projection never blocks an action. */
|
|
5
|
+
export function projectReviewDecision(input) {
|
|
6
|
+
const { store, task, roles, runs, rounds, reviewConfig, currentCandidate } = input;
|
|
7
|
+
const taskRounds = rounds.filter((round) => (round.scope ?? "work-item") === "task");
|
|
8
|
+
const accepted = [...taskRounds]
|
|
9
|
+
.filter((round) => isAcceptedTaskReviewBaseline(store, round))
|
|
10
|
+
.sort(compareRoundIdentity)
|
|
11
|
+
.at(-1);
|
|
12
|
+
const latestAcceptedBaseline = accepted?.taskCandidate === undefined
|
|
13
|
+
? null
|
|
14
|
+
: {
|
|
15
|
+
reviewRoundId: accepted.id,
|
|
16
|
+
reviewerRoleName: accepted.reviewerRoleName,
|
|
17
|
+
candidate: accepted.taskCandidate,
|
|
18
|
+
relationToCurrent: candidateRelation(accepted.taskCandidate, currentCandidate)
|
|
19
|
+
};
|
|
20
|
+
const activeReviews = taskRounds
|
|
21
|
+
.filter((round) => (round.status === "pending" || round.status === "running"))
|
|
22
|
+
.map((round) => {
|
|
23
|
+
const activeRun = runs.find((run) => (run.status === "active" && run.reviewRoundId === round.id));
|
|
24
|
+
const workspaceRoot = round.workspace?.root ?? activeRun?.workspace?.root;
|
|
25
|
+
return {
|
|
26
|
+
reviewRoundId: round.id,
|
|
27
|
+
reviewerRoleName: round.reviewerRoleName,
|
|
28
|
+
mode: round.deltaRecheck === undefined ? "full" : "delta-recheck",
|
|
29
|
+
status: round.status,
|
|
30
|
+
...(activeRun === undefined ? {} : { activeRunId: activeRun.id }),
|
|
31
|
+
startedAt: activeRun?.createdAt ?? round.createdAt,
|
|
32
|
+
frozenCandidate: round.taskCandidate ?? null,
|
|
33
|
+
candidateRelation: candidateRelation(round.taskCandidate ?? null, currentCandidate),
|
|
34
|
+
...(workspaceRoot === undefined ? {} : { workspaceRoot })
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
const reviewerNames = [...new Set([
|
|
38
|
+
...(reviewConfig === null ? [] : [reviewConfig.roleName]),
|
|
39
|
+
...taskRounds.flatMap((round) => [
|
|
40
|
+
round.reviewerRoleName,
|
|
41
|
+
...(round.executionGroup?.lanes.map(({ roleName }) => roleName) ?? [])
|
|
42
|
+
])
|
|
43
|
+
])].sort();
|
|
44
|
+
const reviewers = reviewerNames.map((reviewerRoleName) => {
|
|
45
|
+
const role = roles.find(({ name }) => name === reviewerRoleName);
|
|
46
|
+
if (role === undefined && store.getGlobalRole(reviewerRoleName) === null) {
|
|
47
|
+
return {
|
|
48
|
+
reviewerRoleName,
|
|
49
|
+
status: "unavailable",
|
|
50
|
+
retryable: false
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const availability = projectReviewerAvailability(store, task.id, reviewerRoleName);
|
|
54
|
+
if (availability.kind === "available") {
|
|
55
|
+
return {
|
|
56
|
+
reviewerRoleName,
|
|
57
|
+
status: "available",
|
|
58
|
+
retryable: true
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
reviewerRoleName,
|
|
63
|
+
status: "busy",
|
|
64
|
+
phase: availability.phase,
|
|
65
|
+
...(availability.activeRunId === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { activeRunId: availability.activeRunId }),
|
|
68
|
+
...(availability.activeReviewRoundId === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { activeReviewRoundId: availability.activeReviewRoundId }),
|
|
71
|
+
...(availability.startedAt === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: { startedAt: availability.startedAt }),
|
|
74
|
+
retryable: true,
|
|
75
|
+
retryAfterSeconds: availability.retryAfterSeconds
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
const delta = latestAcceptedBaseline === null
|
|
79
|
+
? {
|
|
80
|
+
technicalAvailability: "unavailable",
|
|
81
|
+
reason: "No accepted Task-final Review baseline is available."
|
|
82
|
+
}
|
|
83
|
+
: latestAcceptedBaseline.relationToCurrent === "exact"
|
|
84
|
+
? {
|
|
85
|
+
technicalAvailability: "unavailable",
|
|
86
|
+
reason: "The accepted baseline already covers the current durable candidate; there is no delta to recheck."
|
|
87
|
+
}
|
|
88
|
+
: currentCandidate === null
|
|
89
|
+
? {
|
|
90
|
+
technicalAvailability: "unavailable",
|
|
91
|
+
reason: "The current durable Task candidate is unavailable."
|
|
92
|
+
}
|
|
93
|
+
: {
|
|
94
|
+
technicalAvailability: "requires-preflight",
|
|
95
|
+
reason: "An accepted baseline exists; Git ancestry, scope and exact diff must be verified."
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
currentCandidate,
|
|
99
|
+
activeReviews,
|
|
100
|
+
reviewers,
|
|
101
|
+
latestAcceptedBaseline,
|
|
102
|
+
delta
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function candidateRelation(candidate, current) {
|
|
106
|
+
if (candidate === null || current === null)
|
|
107
|
+
return "unavailable";
|
|
108
|
+
return isDeepStrictEqual(candidate, current) ? "exact" : "requires-preflight";
|
|
109
|
+
}
|
|
110
|
+
function compareRoundIdentity(left, right) {
|
|
111
|
+
return left.createdAt.localeCompare(right.createdAt)
|
|
112
|
+
|| left.id.localeCompare(right.id, undefined, { numeric: true });
|
|
113
|
+
}
|