@zq-silk/yui 0.6.0 → 0.6.2
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -15,7 +15,11 @@ import { recoverExactAgentRun, terminalizeExactTaskRun, validateExactRunReviewRo
|
|
|
15
15
|
import { resetTaskRoleSessionGeneration } from "../lifecycle/taskRoleSessionReset.js";
|
|
16
16
|
import { copyGlobalRoleToTaskRole, createRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateRole, updateRoleStatus } from "../role/role.js";
|
|
17
17
|
import { createAgentRun } from "../run/agentRun.js";
|
|
18
|
-
import {
|
|
18
|
+
import { matchYieldReceipt } from "../run/yieldReceipt.js";
|
|
19
|
+
import { providerRetryConfig } from "../run/providerRetryConfig.js";
|
|
20
|
+
import { createReviewRound, createTaskReviewRound, attachReviewExecutionGroup, finishReviewRound, parseReviewYieldReport, recordReviewWorkspaceDisposition, retryTaskReviewRound, startReviewRound, updateReviewExecutionGroup, validateTaskReviewCandidate } from "../review/reviewRound.js";
|
|
21
|
+
import { blockingOpenFindings, buildTaskFinalReviewFindingContext, completionGateBlocked, dispositionReviewFinding, planRepairGroups, reconcileReviewFindings, reconcileReviewFindingsAfterReview, reviewFindingLedgerWriteFailed } from "../review/reviewFindingLedger.js";
|
|
22
|
+
import { LEADER_FINDING_DISPOSITIONS } from "../review/reviewFinding.js";
|
|
19
23
|
import { markYuiRunInput, retagYuiRunInput } from "../run/runIdentity.js";
|
|
20
24
|
import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
21
25
|
import { createTaskBrief, updateTaskBrief } from "../brief/taskBrief.js";
|
|
@@ -26,6 +30,7 @@ import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runt
|
|
|
26
30
|
import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, updateTaskMetadata } from "../task/task.js";
|
|
27
31
|
import { formatAgentRunReceiptId, resolveTaskRecordReference } from "../task/taskRecordReference.js";
|
|
28
32
|
import { resolveProject } from "../repository/project.js";
|
|
33
|
+
import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
|
|
29
34
|
import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
30
35
|
import { addExecutionLane, createExecutionGroup, resolveExecutionGroup, restartExecutionLane, updateExecutionLane } from "../execution/executionGroup.js";
|
|
31
36
|
import { sameTaskFinalReviewContract, taskFinalReviewConfig, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
@@ -34,10 +39,17 @@ import { hasAgentConfigOptions, parseRoleOptions, patchRoleAgentBinding, roleOpt
|
|
|
34
39
|
import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./roleSkillValidation.js";
|
|
35
40
|
import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
|
|
36
41
|
import { runTaskContextCommand } from "./taskContextCommand.js";
|
|
42
|
+
import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
|
|
43
|
+
import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
|
|
37
44
|
import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel } from "./taskRoleRuntimeStatus.js";
|
|
38
45
|
import { assertNoOpenInputRequests, openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
|
|
46
|
+
import { runGrantCommand } from "./grantCommands.js";
|
|
47
|
+
import { runWorkflowCommand } from "./workflowCommands.js";
|
|
39
48
|
import { taskActor as resolveTaskActor, taskLeaderActionRunId } from "./taskActor.js";
|
|
40
49
|
import { createTaskTerminalNotification } from "../scheduler/operatorNotification.js";
|
|
50
|
+
import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
|
|
51
|
+
import { collectTaskActionability, computeActionabilityDigest, deriveLeaderRunDisposition } from "../scheduler/actionability.js";
|
|
52
|
+
import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
|
|
41
53
|
import { buildTaskOverview, parseTaskListOptions, renderTaskOverview } from "./taskOverviewCommand.js";
|
|
42
54
|
const LEADER_ROLE = "leader";
|
|
43
55
|
/** Exact Task-final identity or evidence changed between queueing and dispatch. */
|
|
@@ -140,6 +152,12 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
|
|
|
140
152
|
if (incompleteWork !== undefined) {
|
|
141
153
|
throw usageError(`Task ${task.id} has unaccepted work: ${incompleteWork.id}/${incompleteWork.status}.`);
|
|
142
154
|
}
|
|
155
|
+
const activeJob = store.listDurableJobs(task.id).find((job) => (job.status === "queued"
|
|
156
|
+
|| job.status === "running"
|
|
157
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
|
|
158
|
+
if (activeJob !== undefined) {
|
|
159
|
+
throw usageError(`Task ${task.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
|
|
160
|
+
}
|
|
143
161
|
if (task.requireIntegration) {
|
|
144
162
|
if (store.listWorkItems(task.id).length === 0) {
|
|
145
163
|
throw usageError(`Task ${task.id} requires at least one WorkItem before completion.`);
|
|
@@ -157,6 +175,11 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
|
|
|
157
175
|
if (unresolvedIntegration !== undefined) {
|
|
158
176
|
throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
|
|
159
177
|
}
|
|
178
|
+
const unsettledQueueEntry = store.listIntegrationQueueEntries(task.id).find((entry) => (entry.status !== "committed" && entry.status !== "superseded"));
|
|
179
|
+
if (unsettledQueueEntry !== undefined) {
|
|
180
|
+
throw usageError(`Task ${task.id} has an unsettled integration queue entry: `
|
|
181
|
+
+ `${unsettledQueueEntry.id}/${unsettledQueueEntry.status}.`);
|
|
182
|
+
}
|
|
160
183
|
const isolatedWorkspace = store.listManagedWorkspaces(task.id)
|
|
161
184
|
.find(({ owner }) => owner.type === "work-item");
|
|
162
185
|
if (isolatedWorkspace?.owner.type === "work-item") {
|
|
@@ -204,6 +227,7 @@ export function runTaskCommand(args, store, options = {}) {
|
|
|
204
227
|
case "list": return listTaskCommand(rest, store);
|
|
205
228
|
case "show": return showTaskCommand(rest, store);
|
|
206
229
|
case "context": return runTaskContextCommand(rest, store);
|
|
230
|
+
case "next-action": return runTaskNextActionCommand(rest, store);
|
|
207
231
|
case "activate": return output(activateTaskCommand(rest, store, options));
|
|
208
232
|
case "complete": return completeTaskCommand(rest, store, options);
|
|
209
233
|
case "reopen": return output(reopenTaskCommand(rest, store, options));
|
|
@@ -211,8 +235,11 @@ export function runTaskCommand(args, store, options = {}) {
|
|
|
211
235
|
case "retire": return retireTaskCommand(rest, store, options);
|
|
212
236
|
case "reconcile": return output(reconcileTaskCommand(rest, store, options));
|
|
213
237
|
case "message": return output(taskMessageCommand(rest, store, options));
|
|
238
|
+
case "wake": return output(taskWakeCommand(rest, store, options));
|
|
214
239
|
case "project": return taskProjectCommand(rest, store, options);
|
|
215
240
|
case "input": return runTaskInputCommand(rest, store, options);
|
|
241
|
+
case "grant": return runGrantCommand(rest, store, options);
|
|
242
|
+
case "workflow": return runWorkflowCommand(rest, store, options);
|
|
216
243
|
case "role": return taskRoleCommand(rest, store, options);
|
|
217
244
|
case "work": return taskWorkCommand(rest, store, options);
|
|
218
245
|
case "review": return taskReviewCommand(rest, store, options);
|
|
@@ -691,6 +718,22 @@ function completeTaskCommand(args, store, options) {
|
|
|
691
718
|
terminalizedLeaderRun
|
|
692
719
|
};
|
|
693
720
|
}
|
|
721
|
+
// Issue 06: under `review.findingLedger=enforce`, Task completion fails
|
|
722
|
+
// closed on undispositioned open P1/P2 findings. Run this after final-review
|
|
723
|
+
// preparation so `fixed-pending-review` findings can still request the one
|
|
724
|
+
// re-review for a changed frozen head; once no Review is needed, the gate
|
|
725
|
+
// blocks final completion.
|
|
726
|
+
if (completionGateBlocked(tx, task.id)) {
|
|
727
|
+
const blocking = blockingOpenFindings(tx, task.id);
|
|
728
|
+
if (reviewFindingLedgerWriteFailed(tx, task.id)) {
|
|
729
|
+
throw usageError(`Task ${task.id} cannot complete: the Review finding ledger was unavailable `
|
|
730
|
+
+ "while reconciling a semantic Review. Recover the ledger and reconcile the Round "
|
|
731
|
+
+ "before completing the Task.");
|
|
732
|
+
}
|
|
733
|
+
throw usageError(`Task ${task.id} has ${blocking.length} undispositioned open P1/P2 finding(s): `
|
|
734
|
+
+ `${blocking.map(({ id }) => id).join(", ")}. `
|
|
735
|
+
+ "Disposition each finding (yui task review finding dispose) before completing the Task.");
|
|
736
|
+
}
|
|
694
737
|
const completed = completeTask(task, now, { by: actor, summary });
|
|
695
738
|
tx.saveTask(completed);
|
|
696
739
|
tx.clearPendingWakeup(task.id);
|
|
@@ -745,29 +788,45 @@ function completeTaskCommand(args, store, options) {
|
|
|
745
788
|
function reopenTaskCommand(args, store, options) {
|
|
746
789
|
exactPositionals(args, 1, "Task reopen usage: yui task reopen <id>.");
|
|
747
790
|
const now = clock(options);
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
791
|
+
// Reopen changes the Task status that archiveLegacyTaskRefs reads before
|
|
792
|
+
// deleting the legacy ref. Take the same per-Project maintenance fence so a
|
|
793
|
+
// reopen cannot commit between archive's status read and its ref deletion:
|
|
794
|
+
// the two operations are mutually exclusive per Project.
|
|
795
|
+
const existing = store.getTask(args[0]);
|
|
796
|
+
const projectIds = existing === null
|
|
797
|
+
? []
|
|
798
|
+
: existing.projectBindings.map(({ projectId }) => projectId);
|
|
799
|
+
const release = options.yuiHome === undefined || projectIds.length === 0
|
|
800
|
+
? () => { }
|
|
801
|
+
: acquireProjectMaintenanceLocks(options.yuiHome, projectIds);
|
|
802
|
+
try {
|
|
803
|
+
const result = store.transaction((tx) => {
|
|
804
|
+
const task = requireTask(tx, args[0]);
|
|
805
|
+
if (task.status === "active")
|
|
806
|
+
return { task, changed: false };
|
|
807
|
+
if (task.status === "archived")
|
|
808
|
+
throw usageError(`Task is archived: ${task.id}.`);
|
|
809
|
+
if (task.status !== "completed")
|
|
810
|
+
throw usageError(`Task is not completed: ${task.id}.`);
|
|
811
|
+
const active = reopenTask(task, now);
|
|
812
|
+
tx.saveTask(active);
|
|
813
|
+
tx.clearOperatorNotification(task.id);
|
|
814
|
+
enqueueWork(tx, leaderMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
|
|
815
|
+
enqueueWork(tx, taskMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
|
|
816
|
+
recordTaskEvent(tx, task.id, "task.reopened", { status: active.status }, now);
|
|
817
|
+
return { task: active, changed: true };
|
|
818
|
+
});
|
|
819
|
+
if (result.changed) {
|
|
820
|
+
notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
|
|
821
|
+
notifyMailbox(options.runtime, leaderMailbox(result.task.id), result.task.id);
|
|
822
|
+
}
|
|
823
|
+
return result.changed
|
|
824
|
+
? `Reopened task ${result.task.id}\n`
|
|
825
|
+
: `Task ${result.task.id} is already active\n`;
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
release();
|
|
767
829
|
}
|
|
768
|
-
return result.changed
|
|
769
|
-
? `Reopened task ${result.task.id}\n`
|
|
770
|
-
: `Task ${result.task.id} is already active\n`;
|
|
771
830
|
}
|
|
772
831
|
function archiveTaskCommand(args, store, options) {
|
|
773
832
|
const request = validateTaskArchiveRequest(args, store, options);
|
|
@@ -781,9 +840,6 @@ function archiveTaskCommand(args, store, options) {
|
|
|
781
840
|
&& task.status !== "retired") {
|
|
782
841
|
throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
|
|
783
842
|
}
|
|
784
|
-
if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
|
|
785
|
-
throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
|
|
786
|
-
}
|
|
787
843
|
assertNoOpenInputRequests(tx, task.id, "archiving the Task");
|
|
788
844
|
const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
|
|
789
845
|
|| integration.status === "blocked"
|
|
@@ -791,6 +847,15 @@ function archiveTaskCommand(args, store, options) {
|
|
|
791
847
|
if (unresolvedIntegration !== undefined) {
|
|
792
848
|
throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
|
|
793
849
|
}
|
|
850
|
+
const activeArchiveJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
|
|
851
|
+
|| job.status === "running"
|
|
852
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
|
|
853
|
+
if (activeArchiveJob !== undefined) {
|
|
854
|
+
throw usageError(`Task ${task.id} has an active DurableJob: ${activeArchiveJob.id}/${activeArchiveJob.status}.`);
|
|
855
|
+
}
|
|
856
|
+
if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
|
|
857
|
+
throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
|
|
858
|
+
}
|
|
794
859
|
const activeRole = tx.listRoles(task.id)
|
|
795
860
|
.find((role) => tx.getActiveAgentRun(task.id, role.name) !== null);
|
|
796
861
|
if (activeRole !== undefined) {
|
|
@@ -851,6 +916,12 @@ function retireTaskCommand(args, store, options) {
|
|
|
851
916
|
if (unresolvedIntegration !== undefined) {
|
|
852
917
|
throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
|
|
853
918
|
}
|
|
919
|
+
const activeRetireJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
|
|
920
|
+
|| job.status === "running"
|
|
921
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
|
|
922
|
+
if (activeRetireJob !== undefined) {
|
|
923
|
+
throw usageError(`Task ${task.id} has an active DurableJob: ${activeRetireJob.id}/${activeRetireJob.status}.`);
|
|
924
|
+
}
|
|
854
925
|
assertTaskRetirementProof(tx, task, options.taskRetirementProof);
|
|
855
926
|
for (const run of tx.listAgentRuns(task.id).filter(({ status: runStatus }) => (runStatus === "active"))) {
|
|
856
927
|
const terminal = terminalizeExactTaskRun(tx, {
|
|
@@ -982,11 +1053,22 @@ function reconcileTaskCommand(args, store, options) {
|
|
|
982
1053
|
function taskMessageCommand(args, store, options) {
|
|
983
1054
|
const [command, ...rest] = args;
|
|
984
1055
|
if (command === "send") {
|
|
985
|
-
const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->).";
|
|
986
|
-
const parsed = parseTail(rest, new Set(["--body-file"]), usage);
|
|
1056
|
+
const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->) [--wake-policy leader|none].";
|
|
1057
|
+
const parsed = parseTail(rest, new Set(["--body-file", "--wake-policy"]), usage);
|
|
987
1058
|
if (parsed.positionals.length < 1 || parsed.positionals.length > 2)
|
|
988
1059
|
throw usageError(usage);
|
|
989
1060
|
const body = readCommandText(parsed.positionals[1], parsed.options.get("--body-file"), "--body", usage);
|
|
1061
|
+
const wakePolicyRaw = parsed.options.get("--wake-policy");
|
|
1062
|
+
let wakePolicy;
|
|
1063
|
+
if (wakePolicyRaw === undefined) {
|
|
1064
|
+
wakePolicy = undefined;
|
|
1065
|
+
}
|
|
1066
|
+
else if (wakePolicyRaw === "leader" || wakePolicyRaw === "none") {
|
|
1067
|
+
wakePolicy = wakePolicyRaw;
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
throw usageError(`--wake-policy must be 'leader' or 'none': ${wakePolicyRaw}.`);
|
|
1071
|
+
}
|
|
990
1072
|
const now = clock(options);
|
|
991
1073
|
const result = store.transaction((tx) => {
|
|
992
1074
|
const task = requireTask(tx, parsed.positionals[0]);
|
|
@@ -995,9 +1077,14 @@ function taskMessageCommand(args, store, options) {
|
|
|
995
1077
|
const message = actor === "leader"
|
|
996
1078
|
? appendMessage(tx, task.id, body, "role-result", { type: "role", roleName: LEADER_ROLE }, now)
|
|
997
1079
|
: actor === "operator"
|
|
998
|
-
? appendMessage(tx, task.id, body, "operator", { type: "operator" }, now)
|
|
999
|
-
: appendMessage(tx, task.id, body, "user", { type: "user" }, now);
|
|
1000
|
-
|
|
1080
|
+
? appendMessage(tx, task.id, body, "operator", { type: "operator" }, now, { wakePolicy })
|
|
1081
|
+
: appendMessage(tx, task.id, body, "user", { type: "user" }, now, { wakePolicy });
|
|
1082
|
+
// Issue 05: only `wakePolicy=leader` (the default for backward
|
|
1083
|
+
// compatibility) enqueues Leader work. `wakePolicy=none` persists the
|
|
1084
|
+
// message as context without waking the Leader.
|
|
1085
|
+
if (task.status === "active"
|
|
1086
|
+
&& actor !== "leader"
|
|
1087
|
+
&& wakePolicy !== "none") {
|
|
1001
1088
|
enqueueWork(tx, leaderMailbox(task.id), actor === "operator" ? "operator-input" : "user-message", now, [messageRef(task.id, message.id)]);
|
|
1002
1089
|
}
|
|
1003
1090
|
return { task, message, actor };
|
|
@@ -1032,6 +1119,30 @@ function taskMessageCommand(args, store, options) {
|
|
|
1032
1119
|
? "Task message command is required."
|
|
1033
1120
|
: `Unknown command: task message ${command}`);
|
|
1034
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Issue 05: force-wake escape hatch. Bypasses the actionability digest and
|
|
1124
|
+
* enqueues exactly one Leader wakeup with an auditable reason. The reason is
|
|
1125
|
+
* truncated to keep the event payload compact.
|
|
1126
|
+
*/
|
|
1127
|
+
function taskWakeCommand(args, store, options) {
|
|
1128
|
+
const usage = "Task wake usage: yui task wake <id> --force --reason <text>.";
|
|
1129
|
+
const parsed = parseTail(args, new Set(["--reason"]), usage, new Set(["--force"]));
|
|
1130
|
+
exactPositionals(parsed.positionals, 1, usage);
|
|
1131
|
+
if (!parsed.options.has("--force")) {
|
|
1132
|
+
throw usageError("--force is required to wake a Task.", usage);
|
|
1133
|
+
}
|
|
1134
|
+
const reason = requiredOption(parsed.options, "--reason");
|
|
1135
|
+
const now = clock(options);
|
|
1136
|
+
const task = requireTask(store, parsed.positionals[0]);
|
|
1137
|
+
assertTaskOpen(task);
|
|
1138
|
+
const wakeReason = `force-wake:${truncateEventNote(reason)}`;
|
|
1139
|
+
store.transaction((tx) => {
|
|
1140
|
+
queueLeaderWakeup(tx, task.id, wakeReason, now);
|
|
1141
|
+
recordTaskEvent(tx, task.id, "task.wake-forced", { reason: wakeReason }, now);
|
|
1142
|
+
});
|
|
1143
|
+
notifyMailbox(options.runtime, leaderMailbox(task.id), task.id);
|
|
1144
|
+
return `Woke ${task.id} (${wakeReason})\n`;
|
|
1145
|
+
}
|
|
1035
1146
|
function taskRoleCommand(args, store, options) {
|
|
1036
1147
|
const [command, ...rest] = args;
|
|
1037
1148
|
if (command === "add")
|
|
@@ -1448,6 +1559,15 @@ function createWork(args, store, options) {
|
|
|
1448
1559
|
if (new Set(baseRefs.map(({ projectId }) => projectId)).size !== baseRefs.length) {
|
|
1449
1560
|
throw usageError("Each Work Item Project may specify at most one base ref.");
|
|
1450
1561
|
}
|
|
1562
|
+
const guard = runDeliveryGuardPreflight(tx, task.id, {
|
|
1563
|
+
kind: "create-work-item",
|
|
1564
|
+
scope: {
|
|
1565
|
+
title: parsed.positionals[1],
|
|
1566
|
+
objective: parsed.objective ?? parsed.positionals[1],
|
|
1567
|
+
acceptance: parsed.acceptance,
|
|
1568
|
+
writeProjectIds
|
|
1569
|
+
}
|
|
1570
|
+
}, { environment: options.environment, budget: true });
|
|
1451
1571
|
const created = createWorkItem(tx.nextWorkItemId(task.id), task.id, {
|
|
1452
1572
|
title: parsed.positionals[1],
|
|
1453
1573
|
objective: parsed.objective ?? parsed.positionals[1],
|
|
@@ -1459,10 +1579,10 @@ function createWork(args, store, options) {
|
|
|
1459
1579
|
}, now);
|
|
1460
1580
|
tx.saveWorkItem(task.id, created);
|
|
1461
1581
|
enqueueWork(tx, taskMailbox(task.id), "work-created", now, [workItemRef(task.id, created.id)]);
|
|
1462
|
-
return created;
|
|
1582
|
+
return { item: created, guard };
|
|
1463
1583
|
});
|
|
1464
|
-
notifyMailbox(options.runtime, taskMailbox(item.taskId), item.taskId);
|
|
1465
|
-
return output(`Created work item ${item.id} for ${item.taskId}\n
|
|
1584
|
+
notifyMailbox(options.runtime, taskMailbox(item.item.taskId), item.item.taskId);
|
|
1585
|
+
return output(withGuardWarnings(item.guard, `Created work item ${item.item.id} for ${item.item.taskId}\n`), { workItem: item.item });
|
|
1466
1586
|
}
|
|
1467
1587
|
function updateWorkScope(args, store, options) {
|
|
1468
1588
|
const usage = "Task work scope usage: yui task work scope <task>/<work> [--project <project> ...].";
|
|
@@ -1819,6 +1939,24 @@ function dispatchWork(args, store, options) {
|
|
|
1819
1939
|
|| prepared.owner.executionLaneId !== lane.id) {
|
|
1820
1940
|
throw usageError(`Execution Lane workspace identity does not match dispatch: ${runningGroup.id}/${lane.id}.`);
|
|
1821
1941
|
}
|
|
1942
|
+
if (options.laneDispatchProjectPaths !== undefined) {
|
|
1943
|
+
// The prepared workspaces were created under a held maintenance
|
|
1944
|
+
// fence that this transaction still holds. Re-prove the Task
|
|
1945
|
+
// binding set and exact Project paths match the preparation
|
|
1946
|
+
// snapshot: a migrate in the prepare/adopt gap fails closed here
|
|
1947
|
+
// rather than stranding a Lane on the external checkout.
|
|
1948
|
+
const currentIds = task.projectBindings.map(({ projectId }) => projectId).sort();
|
|
1949
|
+
const proofIds = [...options.laneDispatchProjectPaths.keys()].sort();
|
|
1950
|
+
if (currentIds.length !== proofIds.length
|
|
1951
|
+
|| currentIds.some((projectId, index) => projectId !== proofIds[index])) {
|
|
1952
|
+
throw new Error(`Task Project bindings changed during Lane dispatch: ${task.id}.`);
|
|
1953
|
+
}
|
|
1954
|
+
for (const [projectId, preparedPath] of options.laneDispatchProjectPaths) {
|
|
1955
|
+
if (requireProject(tx, projectId).path !== preparedPath) {
|
|
1956
|
+
throw new Error(`Project path changed during Lane dispatch: ${projectId}.`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1822
1960
|
if (tx.getManagedWorkspace(prepared.owner) === null)
|
|
1823
1961
|
tx.saveManagedWorkspace(prepared);
|
|
1824
1962
|
}
|
|
@@ -2018,10 +2156,16 @@ function acceptWork(args, store, options) {
|
|
|
2018
2156
|
if (options.workItemIntegrationProof?.workspace.owner.type === "review-round") {
|
|
2019
2157
|
throw usageError("A ReviewRound-owned workspace cannot be used for WorkItem acceptance.");
|
|
2020
2158
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2159
|
+
// Only diagnostic evidence commits (a reviewer's own commit on top of the
|
|
2160
|
+
// frozen base) are barred from WorkItem acceptance. A clean review
|
|
2161
|
+
// attests the frozen base itself (evidenceCommit === reviewBaseCommit),
|
|
2162
|
+
// which is the candidate's own head.
|
|
2163
|
+
const diagnosticEvidence = new Set(tx.listReviewRounds(item.taskId)
|
|
2164
|
+
.flatMap(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit
|
|
2165
|
+
? [evidenceCommit]
|
|
2166
|
+
: []));
|
|
2167
|
+
if (options.workItemIntegrationProof?.projects.some(({ headCommit }) => diagnosticEvidence.has(headCommit))) {
|
|
2168
|
+
throw usageError("A ReviewRound diagnostic evidence commit cannot be used for WorkItem acceptance.");
|
|
2025
2169
|
}
|
|
2026
2170
|
const candidate = requireWorkItemCandidate(item);
|
|
2027
2171
|
const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
|
|
@@ -2175,6 +2319,19 @@ function retireWork(args, store, options) {
|
|
|
2175
2319
|
throw usageError("A Work Item cannot replace itself.");
|
|
2176
2320
|
}
|
|
2177
2321
|
}
|
|
2322
|
+
// rr4/finding-5: A Work Item with an active DurableJob cannot be retired —
|
|
2323
|
+
// the runner may still be using its workspace. Block on queued, running,
|
|
2324
|
+
// and unacknowledged unknown-needs-attention jobs owned by this Work Item.
|
|
2325
|
+
const activeWorkItemJob = tx.listDurableJobs(task.id).find((job) => (job.owner.kind === "work-item"
|
|
2326
|
+
&& job.owner.workItemId === item.id
|
|
2327
|
+
&& (job.status === "queued"
|
|
2328
|
+
|| job.status === "running"
|
|
2329
|
+
|| (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
|
|
2330
|
+
if (activeWorkItemJob !== undefined) {
|
|
2331
|
+
throw usageError(`Work Item ${item.id} has an active DurableJob: `
|
|
2332
|
+
+ `${activeWorkItemJob.id}/${activeWorkItemJob.status}. `
|
|
2333
|
+
+ "Cancel or acknowledge it before retiring.");
|
|
2334
|
+
}
|
|
2178
2335
|
for (const run of tx.listAgentRuns(task.id).filter((candidate) => (candidate.status === "active" && candidate.workItemId === item.id))) {
|
|
2179
2336
|
const terminal = terminalizeExactTaskRun(tx, {
|
|
2180
2337
|
taskId: task.id,
|
|
@@ -2285,13 +2442,20 @@ function reviewWork(args, store, options) {
|
|
|
2285
2442
|
&& round.candidateId === candidate.id
|
|
2286
2443
|
&& (round.status === "pending" || round.status === "running")))).at(-1);
|
|
2287
2444
|
if (activeRound !== undefined) {
|
|
2445
|
+
if (activeRound.status === "pending" && activeRound.reviewerRunId === undefined) {
|
|
2446
|
+
return { round: activeRound, run: null, resumed: true };
|
|
2447
|
+
}
|
|
2288
2448
|
throw usageError(`ReviewRound is already active: ${activeRound.id}/${activeRound.status}.`);
|
|
2289
2449
|
}
|
|
2290
|
-
|
|
2450
|
+
const queued = queueReviewRound(tx, item, config, "leader", now);
|
|
2451
|
+
return { ...queued, resumed: false };
|
|
2291
2452
|
});
|
|
2292
2453
|
if (result.run !== null) {
|
|
2293
2454
|
notifyReviewMailbox(options, options.runtime, roleMailbox(result.run.taskId, result.run.roleName), result.run.taskId);
|
|
2294
2455
|
}
|
|
2456
|
+
if (result.resumed) {
|
|
2457
|
+
return output(`Review request ${result.round.id} is pending; resuming dispatch.\n`, { reviewRound: result.round });
|
|
2458
|
+
}
|
|
2295
2459
|
return result.round.status === "failed"
|
|
2296
2460
|
? output(`Review could not start for ${result.round.workItemId}: ${result.round.summary}\n`, { reviewRound: result.round })
|
|
2297
2461
|
: output(`Review requested as ${result.round.id}\n`, { reviewRound: result.round });
|
|
@@ -2313,6 +2477,8 @@ function taskReviewCommand(args, store, options) {
|
|
|
2313
2477
|
return retryFailedTaskReviewRound(rest, store, options);
|
|
2314
2478
|
if (command === "group")
|
|
2315
2479
|
return resolveReviewExecutionGroup(rest, store, options);
|
|
2480
|
+
if (command === "finding")
|
|
2481
|
+
return reviewFindingCommand(rest, store, options);
|
|
2316
2482
|
throw usageError(command === undefined
|
|
2317
2483
|
? "Task review command is required."
|
|
2318
2484
|
: `Unknown command: task review ${command}`);
|
|
@@ -2355,29 +2521,32 @@ function resolveReviewExecutionGroup(args, store, options) {
|
|
|
2355
2521
|
: selectedLaneIds === undefined ? {} : { selectedLaneIds })
|
|
2356
2522
|
}, now);
|
|
2357
2523
|
const withGroup = updateReviewExecutionGroup(round, resolved);
|
|
2358
|
-
const
|
|
2359
|
-
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
|
|
2524
|
+
const selectedLanes = resolved.lanes
|
|
2525
|
+
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false);
|
|
2526
|
+
const laneReports = selectedLanes
|
|
2360
2527
|
.map((lane) => lane.result?.report ?? lane.result?.summary ?? "")
|
|
2361
2528
|
.filter((report) => report.length > 0);
|
|
2362
|
-
const checks =
|
|
2363
|
-
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
|
|
2529
|
+
const checks = selectedLanes
|
|
2364
2530
|
.flatMap((lane) => lane.result?.checks ?? [])
|
|
2365
2531
|
.map(({ name, outcome, details }) => ({
|
|
2366
2532
|
name,
|
|
2367
2533
|
outcome,
|
|
2368
2534
|
...(details === undefined ? {} : { details })
|
|
2369
2535
|
}));
|
|
2370
|
-
const findings =
|
|
2371
|
-
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
|
|
2536
|
+
const findings = selectedLanes
|
|
2372
2537
|
.flatMap((lane) => lane.result?.findings ?? []);
|
|
2373
|
-
const evidence =
|
|
2374
|
-
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
|
|
2538
|
+
const evidence = selectedLanes
|
|
2375
2539
|
.flatMap((lane) => lane.result?.evidence ?? []);
|
|
2376
|
-
const evidenceCommits = [...new Set(
|
|
2377
|
-
.filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
|
|
2540
|
+
const evidenceCommits = [...new Set(selectedLanes
|
|
2378
2541
|
.map((lane) => lane.result?.evidenceCommit)
|
|
2379
2542
|
.filter((commit) => commit !== undefined))];
|
|
2380
|
-
|
|
2543
|
+
// A Round attests a single tree only when EVERY selected Lane attests it.
|
|
2544
|
+
// A dirty Lane (no evidenceCommit) ran checks on an uncommitted tree, so its
|
|
2545
|
+
// checks cannot be covered by another Lane's base attestation.
|
|
2546
|
+
const allLanesAttest = selectedLanes.every((lane) => lane.result?.evidenceCommit !== undefined);
|
|
2547
|
+
const evidenceCommit = allLanesAttest && evidenceCommits.length === 1
|
|
2548
|
+
? evidenceCommits[0]
|
|
2549
|
+
: undefined;
|
|
2381
2550
|
const terminal = finishReviewRound(withGroup, decision === "accept" ? "completed" : "failed", summary, now, {
|
|
2382
2551
|
report: [
|
|
2383
2552
|
laneReports.join("\n\n") || summary,
|
|
@@ -2393,6 +2562,11 @@ function resolveReviewExecutionGroup(args, store, options) {
|
|
|
2393
2562
|
...(evidenceCommit === undefined ? {} : { evidenceCommit })
|
|
2394
2563
|
});
|
|
2395
2564
|
tx.saveReviewRound(task.id, terminal);
|
|
2565
|
+
// Issue 06: a panel-resolved completed Round feeds the finding ledger;
|
|
2566
|
+
// a rejected Round is an execution-attempt failure and is skipped.
|
|
2567
|
+
if (terminal.status === "completed") {
|
|
2568
|
+
reconcileReviewFindingsAfterReview(tx, task.id, terminal.id, now);
|
|
2569
|
+
}
|
|
2396
2570
|
enqueueWork(tx, leaderMailbox(task.id), "review-group-resolved", now, [
|
|
2397
2571
|
workItemRef(task.id, round.workItemId)
|
|
2398
2572
|
]);
|
|
@@ -2400,6 +2574,166 @@ function resolveReviewExecutionGroup(args, store, options) {
|
|
|
2400
2574
|
});
|
|
2401
2575
|
return output(`Resolved Review ExecutionGroup ${result.executionGroup?.id ?? "unknown"} as ${decision}; ReviewRound ${result.id} is ${result.status}.\n`, { reviewRound: result });
|
|
2402
2576
|
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Issue 06: `yui task review finding` — the cross-Round finding ledger CLI.
|
|
2579
|
+
* Findings are extracted automatically from completed Rounds; these commands
|
|
2580
|
+
* let the Leader inspect the ledger, disposition each finding, and plan the
|
|
2581
|
+
* parallel repair wave.
|
|
2582
|
+
*/
|
|
2583
|
+
function reviewFindingCommand(args, store, options) {
|
|
2584
|
+
const [command, ...rest] = args;
|
|
2585
|
+
if (command === "list")
|
|
2586
|
+
return listReviewFindings(rest, store, options);
|
|
2587
|
+
if (command === "dispose")
|
|
2588
|
+
return disposeReviewFindingCommand(rest, store, options);
|
|
2589
|
+
if (command === "repair-wave")
|
|
2590
|
+
return planReviewRepairWave(rest, store, options);
|
|
2591
|
+
if (command === "extract")
|
|
2592
|
+
return extractReviewFindingsCommand(rest, store, options);
|
|
2593
|
+
throw usageError(command === undefined
|
|
2594
|
+
? "Task review finding command is required."
|
|
2595
|
+
: `Unknown command: task review finding ${command}`);
|
|
2596
|
+
}
|
|
2597
|
+
function listReviewFindings(args, store, options) {
|
|
2598
|
+
const usage = "Task review finding list usage: yui task review finding list <task>.";
|
|
2599
|
+
exactPositionals(args, 1, usage);
|
|
2600
|
+
const task = requireTask(store, args[0]);
|
|
2601
|
+
const findings = store.listReviewFindings(task.id);
|
|
2602
|
+
if (findings.length === 0) {
|
|
2603
|
+
return output(`No review findings recorded for ${task.id}.\n`);
|
|
2604
|
+
}
|
|
2605
|
+
const lines = findings.map((finding) => {
|
|
2606
|
+
const repair = finding.repair === undefined
|
|
2607
|
+
? ""
|
|
2608
|
+
: `; repair: ${finding.repair.workItemId ?? "?"}${finding.repair.commit === undefined ? "" : `@${finding.repair.commit.slice(0, 12)}`}`;
|
|
2609
|
+
const merge = finding.mergeRequired === true ? " [merge-required]" : "";
|
|
2610
|
+
return `${finding.id} [${finding.severity}/${finding.disposition}] ${finding.title}`
|
|
2611
|
+
+ ` (invariant: ${finding.invariant}; first: ${finding.firstReviewRoundId}; last: ${finding.lastReviewRoundId})${repair}${merge}`;
|
|
2612
|
+
});
|
|
2613
|
+
return output(`Review findings for ${task.id}:\n${lines.join("\n")}\n`);
|
|
2614
|
+
}
|
|
2615
|
+
function disposeReviewFindingCommand(args, store, options) {
|
|
2616
|
+
const usage = "Task review finding dispose usage: yui task review finding dispose <task>/<finding> "
|
|
2617
|
+
+ "--disposition <fixed-pending-review|verified-fixed|accepted-risk|not-actionable|superseded> "
|
|
2618
|
+
+ "[--work-item <id>] [--commit <sha>] [--verification <text>] [--note <text>] [--superseded-by <stable-key>].";
|
|
2619
|
+
const parsed = parseTail(args, new Set(["--disposition", "--work-item", "--commit", "--verification", "--note", "--superseded-by"]), usage);
|
|
2620
|
+
exactPositionals(parsed.positionals, 1, usage);
|
|
2621
|
+
const disposition = requiredOption(parsed.options, "--disposition");
|
|
2622
|
+
if (!LEADER_FINDING_DISPOSITIONS.includes(disposition)) {
|
|
2623
|
+
throw usageError(`Review finding disposition is invalid: ${disposition}.`);
|
|
2624
|
+
}
|
|
2625
|
+
const now = clock(options);
|
|
2626
|
+
const reference = resolveTaskRecordReference(parsed.positionals[0], {
|
|
2627
|
+
kind: "reviewFinding",
|
|
2628
|
+
label: "Review finding"
|
|
2629
|
+
});
|
|
2630
|
+
const result = store.transaction((tx) => {
|
|
2631
|
+
const task = requireTask(tx, reference.taskId);
|
|
2632
|
+
if (task.status !== "active")
|
|
2633
|
+
throw usageError(inactiveTaskMessage(task, "dispositioning a review finding"));
|
|
2634
|
+
if (taskActor(options, task.id) !== "leader") {
|
|
2635
|
+
throw usageError("Only the Task Leader may disposition a review finding.");
|
|
2636
|
+
}
|
|
2637
|
+
const command = {
|
|
2638
|
+
disposition,
|
|
2639
|
+
by: taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome) ?? "leader",
|
|
2640
|
+
...(parsed.options.get("--note") === undefined ? {} : { note: parsed.options.get("--note") }),
|
|
2641
|
+
...(parsed.options.get("--work-item") === undefined ? {} : { workItemId: parsed.options.get("--work-item") }),
|
|
2642
|
+
...(parsed.options.get("--commit") === undefined ? {} : { commit: parsed.options.get("--commit") }),
|
|
2643
|
+
...(parsed.options.get("--verification") === undefined ? {} : { verification: parsed.options.get("--verification") }),
|
|
2644
|
+
...(parsed.options.get("--superseded-by") === undefined ? {} : { supersededBy: parsed.options.get("--superseded-by") }),
|
|
2645
|
+
now
|
|
2646
|
+
};
|
|
2647
|
+
return dispositionReviewFinding(tx, task.id, reference.localId, command);
|
|
2648
|
+
});
|
|
2649
|
+
return output(`Dispositioned ${result.id} as ${result.disposition}.\n`);
|
|
2650
|
+
}
|
|
2651
|
+
function planReviewRepairWave(args, store, options) {
|
|
2652
|
+
const usage = "Task review finding repair-wave usage: yui task review finding repair-wave <task> [--create].";
|
|
2653
|
+
const parsed = parseTail(args, new Set(), usage, new Set(["--create"]));
|
|
2654
|
+
exactPositionals(parsed.positionals, 1, usage);
|
|
2655
|
+
const task = requireTask(store, parsed.positionals[0]);
|
|
2656
|
+
const groups = planRepairGroups(store, task.id);
|
|
2657
|
+
if (groups.length === 0) {
|
|
2658
|
+
return output(`No open P1/P2 findings need repair for ${task.id}.\n`);
|
|
2659
|
+
}
|
|
2660
|
+
if (parsed.options.has("--create")) {
|
|
2661
|
+
const now = clock(options);
|
|
2662
|
+
const created = store.transaction((tx) => {
|
|
2663
|
+
const currentTask = requireTask(tx, task.id);
|
|
2664
|
+
if (currentTask.status !== "active") {
|
|
2665
|
+
throw usageError(inactiveTaskMessage(currentTask, "creating a review repair wave"));
|
|
2666
|
+
}
|
|
2667
|
+
if (taskActor(options, currentTask.id) !== "leader") {
|
|
2668
|
+
throw usageError("Only the Task Leader may create a review repair wave.");
|
|
2669
|
+
}
|
|
2670
|
+
const openItems = tx.listWorkItems(currentTask.id)
|
|
2671
|
+
.filter((item) => item.status === "pending" || item.status === "running");
|
|
2672
|
+
return groups.map((group) => {
|
|
2673
|
+
const findingMarkers = group.findingIds.map((id) => `review-finding:${id}`);
|
|
2674
|
+
const existing = openItems.find((item) => isDeepStrictEqual([...item.acceptance].sort(), [...findingMarkers].sort()));
|
|
2675
|
+
if (existing !== undefined)
|
|
2676
|
+
return { group, item: existing, changed: false };
|
|
2677
|
+
const item = createWorkItem(tx.nextWorkItemId(currentTask.id), currentTask.id, {
|
|
2678
|
+
title: `Repair review findings ${group.findingIds.join(", ")}`,
|
|
2679
|
+
objective: [
|
|
2680
|
+
"Repair the following Task-final Review findings as one overlapping group:",
|
|
2681
|
+
...group.findings.map((finding) => (`- ${finding.id} [${finding.severity}] ${finding.title} `
|
|
2682
|
+
+ `(invariant: ${finding.invariant}; evidence: ${finding.evidence.join(" | ") || "see ReviewRound"})`)),
|
|
2683
|
+
"Integrate this repair with the rest of the Task before requesting another Task-final Review."
|
|
2684
|
+
].join("\n"),
|
|
2685
|
+
acceptance: findingMarkers,
|
|
2686
|
+
writeProjectIds: reviewRepairProjectIds(currentTask, group.affectedPaths)
|
|
2687
|
+
}, now);
|
|
2688
|
+
tx.saveWorkItem(currentTask.id, item);
|
|
2689
|
+
enqueueWork(tx, taskMailbox(currentTask.id), "work-created", now, [
|
|
2690
|
+
workItemRef(currentTask.id, item.id)
|
|
2691
|
+
]);
|
|
2692
|
+
return { group, item, changed: true };
|
|
2693
|
+
});
|
|
2694
|
+
});
|
|
2695
|
+
const lines = created.map(({ group, item, changed }) => (`wave ${group.groupKey}: ${item.id} ${changed ? "created" : "already open"} `
|
|
2696
|
+
+ `(${group.findingIds.join(", ")})`));
|
|
2697
|
+
return output(`Review repair wave for ${task.id} (${groups.length} group(s)):\n${lines.join("\n")}\n`, { groups: created });
|
|
2698
|
+
}
|
|
2699
|
+
const lines = groups.map((group, index) => {
|
|
2700
|
+
const findings = group.findings
|
|
2701
|
+
.map((finding) => `${finding.id} [${finding.severity}] ${finding.title}`)
|
|
2702
|
+
.join("; ");
|
|
2703
|
+
return `wave ${index + 1}: ${findings}`
|
|
2704
|
+
+ ` (paths: ${group.affectedPaths.join(", ") || "none"}; invariants: ${group.invariants.join(", ")})`;
|
|
2705
|
+
});
|
|
2706
|
+
return output(`Repair wave for ${task.id} (${groups.length} group(s), run disjoint groups in parallel):\n${lines.join("\n")}\n`);
|
|
2707
|
+
}
|
|
2708
|
+
function reviewRepairProjectIds(task, affectedPaths) {
|
|
2709
|
+
const bindings = task.projectBindings;
|
|
2710
|
+
const matched = bindings.filter((binding) => affectedPaths.some((path) => pathWithinProject(path, binding.directory)));
|
|
2711
|
+
const projectIds = (matched.length > 0 ? matched : bindings).map(({ projectId }) => projectId);
|
|
2712
|
+
return [...new Set(projectIds)];
|
|
2713
|
+
}
|
|
2714
|
+
function pathWithinProject(path, directory) {
|
|
2715
|
+
const normalizedPath = path.replace(/^\.\//u, "").replace(/^\/+/u, "");
|
|
2716
|
+
const normalizedDirectory = directory.replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, "");
|
|
2717
|
+
if (normalizedDirectory.length === 0 || normalizedDirectory === ".")
|
|
2718
|
+
return true;
|
|
2719
|
+
return normalizedPath === normalizedDirectory
|
|
2720
|
+
|| normalizedPath.startsWith(`${normalizedDirectory}/`);
|
|
2721
|
+
}
|
|
2722
|
+
function extractReviewFindingsCommand(args, store, options) {
|
|
2723
|
+
const usage = "Task review finding extract usage: yui task review finding extract <task>/<review-round>.";
|
|
2724
|
+
exactPositionals(args, 1, usage);
|
|
2725
|
+
const reference = resolveTaskRecordReference(args[0], {
|
|
2726
|
+
kind: "reviewRound",
|
|
2727
|
+
label: "ReviewRound"
|
|
2728
|
+
});
|
|
2729
|
+
const now = clock(options);
|
|
2730
|
+
const result = store.transaction((tx) => reconcileReviewFindings(tx, reference.taskId, reference.localId, now));
|
|
2731
|
+
if (result.skipped) {
|
|
2732
|
+
return output(`ReviewRound ${result.roundId} produced no findings: ${result.reason ?? "skipped"}\n`);
|
|
2733
|
+
}
|
|
2734
|
+
return output(`Reconciled ${result.created.length + result.updated.length + result.conflicts.length} finding(s) from ${result.roundId}: `
|
|
2735
|
+
+ `${result.created.length} created, ${result.updated.length} updated, ${result.conflicts.length} conflict(s).\n`);
|
|
2736
|
+
}
|
|
2403
2737
|
function requestTaskReviewRound(args, store, options) {
|
|
2404
2738
|
const usage = "Task review request usage: yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...].";
|
|
2405
2739
|
const parsed = parseMultiValueTail(args, new Set(["--role", "--strategy"]), new Set(["--lane-role"]), usage);
|
|
@@ -2633,9 +2967,12 @@ function retryFailedTaskReviewRound(args, store, options) {
|
|
|
2633
2967
|
throw usageError(`ReviewRound ${round.id} is not a failed Task-final ReviewRound.`);
|
|
2634
2968
|
}
|
|
2635
2969
|
if (round.reviewerRunId !== undefined) {
|
|
2970
|
+
if (round.status === "completed") {
|
|
2971
|
+
throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
|
|
2972
|
+
}
|
|
2636
2973
|
throw usageError(`ReviewRound ${round.id} has Reviewer Run ${round.reviewerRunId}; use task run retry instead.`);
|
|
2637
2974
|
}
|
|
2638
|
-
if (round.status !== "failed") {
|
|
2975
|
+
if (round.status !== "failed" && round.status !== "pending") {
|
|
2639
2976
|
throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
|
|
2640
2977
|
}
|
|
2641
2978
|
if (round.taskCandidate === undefined) {
|
|
@@ -2667,25 +3004,15 @@ function retryFailedTaskReviewRound(args, store, options) {
|
|
|
2667
3004
|
if (roundIndex < 0) {
|
|
2668
3005
|
throw dataError(`Final ReviewRound is not in Task history: ${round.id}.`);
|
|
2669
3006
|
}
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
&& entry.requestedBy === "leader"
|
|
2674
|
-
&& entry.workItemId === round.workItemId
|
|
2675
|
-
&& entry.candidateId === round.candidateId
|
|
2676
|
-
&& entry.reviewerRoleName === round.reviewerRoleName
|
|
2677
|
-
&& entry.reviewBaseCommit === round.reviewBaseCommit
|
|
2678
|
-
&& sameTaskFinalReviewContract(entry.taskFinalReviewContract, taskFinalContract)
|
|
2679
|
-
&& isSameTaskReviewCandidate(entry.taskCandidate, round.taskCandidate)
|
|
2680
|
-
&& entry.status !== "failed")).at(-1);
|
|
2681
|
-
const conflictingLater = laterRounds.find((entry) => entry.id !== existingRetry?.id);
|
|
3007
|
+
// Issue 06: infra retries reuse the same semantic Round ID. Any later
|
|
3008
|
+
// Round supersedes this one; an active Round for the same Reviewer blocks.
|
|
3009
|
+
const conflictingLater = reviewerRounds.slice(roundIndex + 1).at(-1);
|
|
2682
3010
|
if (conflictingLater !== undefined) {
|
|
2683
3011
|
throw usageError(`A newer conflicting final ReviewRound already exists after ${round.id}: `
|
|
2684
3012
|
+ `${conflictingLater.id}/${conflictingLater.status}.`);
|
|
2685
3013
|
}
|
|
2686
|
-
assertNoConflictingTaskReviewRound(reviewerRounds,
|
|
3014
|
+
assertNoConflictingTaskReviewRound(reviewerRounds, round.id);
|
|
2687
3015
|
const activeRound = reviewerRounds.find((entry) => (entry.id !== round.id
|
|
2688
|
-
&& entry.id !== existingRetry?.id
|
|
2689
3016
|
&& entry.reviewerRoleName === round.reviewerRoleName
|
|
2690
3017
|
&& (entry.status === "pending" || entry.status === "running")));
|
|
2691
3018
|
if (activeRound !== undefined) {
|
|
@@ -2711,62 +3038,27 @@ function retryFailedTaskReviewRound(args, store, options) {
|
|
|
2711
3038
|
|| mailbox?.pending !== null && mailbox?.pending !== undefined);
|
|
2712
3039
|
const activeReviewerRuns = tx.listAgentRuns(task.id).filter((entry) => (entry.roleName === reviewer.name && entry.status === "active"));
|
|
2713
3040
|
const activePointer = tx.getActiveAgentRun(task.id, reviewer.name);
|
|
2714
|
-
// An exact already-created retry is the idempotent result. The identity
|
|
2715
|
-
// fences are intentionally as strict as the failed-Run retry path: pending
|
|
2716
|
-
// is reusable only while every Reviewer lane is idle; running is reusable
|
|
2717
|
-
// only with its exact active Run/mailbox; completed is a no-write result.
|
|
2718
|
-
if (existingRetry !== undefined) {
|
|
2719
|
-
if (existingRetry.status === "pending") {
|
|
2720
|
-
if (activePointer !== null || activeReviewerRuns.length > 0
|
|
2721
|
-
|| hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
2722
|
-
throw usageError(`Reviewer has unrelated active execution: ${reviewer.name}.`);
|
|
2723
|
-
}
|
|
2724
|
-
return { round: existingRetry, created: false };
|
|
2725
|
-
}
|
|
2726
|
-
if (existingRetry.status === "running") {
|
|
2727
|
-
const reviewerRunId = existingRetry.reviewerRunId;
|
|
2728
|
-
const activeMatches = reviewerRunId !== undefined
|
|
2729
|
-
&& activePointer !== null
|
|
2730
|
-
&& activePointer.id === reviewerRunId
|
|
2731
|
-
&& activePointer.status === "active"
|
|
2732
|
-
&& activeReviewerRuns.length === 1
|
|
2733
|
-
&& activeReviewerRuns[0].id === reviewerRunId;
|
|
2734
|
-
const processingMatches = reviewerRunId !== undefined
|
|
2735
|
-
&& reviewerMailbox?.processing?.executionRef !== undefined
|
|
2736
|
-
&& isDeepStrictEqual(reviewerMailbox.processing.executionRef, runRef(task.id, reviewerRunId))
|
|
2737
|
-
&& reviewerMailbox.pending === null;
|
|
2738
|
-
const pendingMatches = reviewerRunId !== undefined
|
|
2739
|
-
&& reviewerMailbox?.pending?.requestCount === 1
|
|
2740
|
-
&& reviewerMailbox.pending.refs.some((ref) => (isDeepStrictEqual(ref, runRef(task.id, reviewerRunId))));
|
|
2741
|
-
if (!activeMatches || (!processingMatches && !pendingMatches) || hasMailboxWork(runtimeMailbox)) {
|
|
2742
|
-
throw usageError(`Existing retry ${existingRetry.id} is running without its exact active Reviewer execution.`);
|
|
2743
|
-
}
|
|
2744
|
-
return { round: existingRetry, created: false };
|
|
2745
|
-
}
|
|
2746
|
-
if (existingRetry.status === "completed") {
|
|
2747
|
-
if (activePointer !== null || activeReviewerRuns.length > 0
|
|
2748
|
-
|| hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
2749
|
-
throw usageError(`Reviewer has unrelated active execution: ${reviewer.name}.`);
|
|
2750
|
-
}
|
|
2751
|
-
return { round: existingRetry, created: false };
|
|
2752
|
-
}
|
|
2753
|
-
throw usageError(`Existing retry ${existingRetry.id} is not reusable from ${existingRetry.status}.`);
|
|
2754
|
-
}
|
|
2755
3041
|
if (activePointer !== null || activeReviewerRuns.length > 0) {
|
|
2756
3042
|
throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
|
|
2757
3043
|
}
|
|
2758
3044
|
if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
2759
3045
|
throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
|
|
2760
3046
|
}
|
|
2761
|
-
|
|
2762
|
-
|
|
3047
|
+
// Issue 06: an already-pending Round is the idempotent retry result.
|
|
3048
|
+
if (round.status === "pending") {
|
|
3049
|
+
return { round, created: false };
|
|
3050
|
+
}
|
|
3051
|
+
// Issue 06: infra retry resets the same semantic Round to pending instead
|
|
3052
|
+
// of manufacturing a new Round, so Round count and finding identity stay
|
|
3053
|
+
// stable across execution-attempt failures.
|
|
3054
|
+
const resetRound = retryTaskReviewRound(round);
|
|
3055
|
+
tx.saveReviewRound(task.id, resetRound);
|
|
2763
3056
|
recordTaskEvent(tx, task.id, "review.task-final-retried", {
|
|
2764
|
-
|
|
2765
|
-
nextReviewRoundId: nextRound.id,
|
|
3057
|
+
reviewRoundId: round.id,
|
|
2766
3058
|
workItemId: round.workItemId,
|
|
2767
3059
|
candidateId: round.candidateId
|
|
2768
3060
|
}, now);
|
|
2769
|
-
return { round:
|
|
3061
|
+
return { round: resetRound, created: true };
|
|
2770
3062
|
});
|
|
2771
3063
|
return output(result.created
|
|
2772
3064
|
? `Task-final Review retry requested as ${result.round.id}\n`
|
|
@@ -2784,6 +3076,8 @@ function taskRunCommand(args, store, options) {
|
|
|
2784
3076
|
return output(recoverRun(rest, store, options));
|
|
2785
3077
|
if (command === "yield")
|
|
2786
3078
|
return yieldRun(rest, store, options);
|
|
3079
|
+
if (command === "yield-status")
|
|
3080
|
+
return yieldRunStatus(rest, store, options);
|
|
2787
3081
|
if (command === "checkpoint")
|
|
2788
3082
|
return output(checkpointRun(rest, store, options));
|
|
2789
3083
|
throw usageError(command === undefined
|
|
@@ -3436,11 +3730,9 @@ function latestTaskReviewContractAnchor(store, task, taskFinalContract) {
|
|
|
3436
3730
|
}
|
|
3437
3731
|
/**
|
|
3438
3732
|
* Leader-only retry of an exact failed Task-final review Run. The old failed
|
|
3439
|
-
* Run
|
|
3440
|
-
*
|
|
3441
|
-
*
|
|
3442
|
-
* prior retry already produced one). Every identity and frozen-head fence is
|
|
3443
|
-
* checked inside one transaction so a partial fail-old-without-new state can
|
|
3733
|
+
* Run remains the attempt trail, while the semantic ReviewRound is reset to
|
|
3734
|
+
* pending under its existing identity. Every identity and frozen-head fence is
|
|
3735
|
+
* checked inside one transaction so a partial fail-old-without-reset state can
|
|
3444
3736
|
* never be committed.
|
|
3445
3737
|
*/
|
|
3446
3738
|
function retryFailedReviewRun(previous, store, options, now) {
|
|
@@ -3470,13 +3762,12 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3470
3762
|
if (!sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalContract)) {
|
|
3471
3763
|
throw usageError(`Task final-review contract does not match ReviewRound ${round.id}.`);
|
|
3472
3764
|
}
|
|
3473
|
-
if (round.status !== "failed"
|
|
3765
|
+
if (round.status !== "failed"
|
|
3766
|
+
&& round.status !== "running"
|
|
3767
|
+
&& round.status !== "pending"
|
|
3768
|
+
&& round.status !== "completed") {
|
|
3474
3769
|
throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
|
|
3475
3770
|
}
|
|
3476
|
-
const validation = validateExactRunReviewRound(tx, run, { allowTerminal: true });
|
|
3477
|
-
if (validation.disposition !== "applied" || validation.round === null) {
|
|
3478
|
-
throw usageError(`Review Run ${run.id} identity does not match its ReviewRound or frozen Task state changed: ${validation.reason ?? "mismatch"}.`);
|
|
3479
|
-
}
|
|
3480
3771
|
const currentTaskCandidate = actualTaskReviewCandidateForMutation(tx, task, options);
|
|
3481
3772
|
if (!isSameTaskReviewCandidate(currentTaskCandidate, round.taskCandidate)) {
|
|
3482
3773
|
throw usageError(`Task-final ReviewRound ${round.id} no longer matches the latest committed Integration heads.`);
|
|
@@ -3499,27 +3790,9 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3499
3790
|
if (laterRound !== undefined && !isSameTaskReviewCandidate(laterRound.taskCandidate, round.taskCandidate)) {
|
|
3500
3791
|
throw usageError(`A newer final Task candidate already has ReviewRound ${laterRound.id}.`);
|
|
3501
3792
|
}
|
|
3502
|
-
const reviewerRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id).filter((entry) => (entry.workItemId === item.id
|
|
3503
|
-
&& entry.candidateId === candidate.id
|
|
3504
|
-
&& entry.reviewerRoleName === round.reviewerRoleName)));
|
|
3505
3793
|
const allReviewerRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id).filter((entry) => (entry.reviewerRoleName === round.reviewerRoleName)));
|
|
3506
|
-
|
|
3507
|
-
&& entry.status !== "failed"
|
|
3508
|
-
&& entry.requestedBy === "leader"
|
|
3509
|
-
&& (entry.scope ?? "work-item") === "task"
|
|
3510
|
-
&& entry.candidateId === candidate.id
|
|
3511
|
-
&& entry.workItemId === item.id
|
|
3512
|
-
&& entry.reviewerRoleName === round.reviewerRoleName
|
|
3513
|
-
&& entry.reviewBaseCommit === round.reviewBaseCommit
|
|
3514
|
-
&& sameTaskFinalReviewContract(entry.taskFinalReviewContract, taskFinalContract)
|
|
3515
|
-
&& isSameTaskReviewCandidate(entry.taskCandidate, round.taskCandidate))).at(-1);
|
|
3516
|
-
if (round.status === "running"
|
|
3517
|
-
&& tx.getActiveAgentRun(task.id, round.reviewerRoleName) !== null) {
|
|
3518
|
-
throw usageError(`${task.id}/${round.reviewerRoleName} already has an active run.`);
|
|
3519
|
-
}
|
|
3520
|
-
assertNoConflictingTaskReviewRound(tx.listReviewRounds(task.id), [round.id, ...(existingRetry === undefined ? [] : [existingRetry.id])]);
|
|
3794
|
+
assertNoConflictingTaskReviewRound(tx.listReviewRounds(task.id), round.id);
|
|
3521
3795
|
const activeRound = allReviewerRounds.find((entry) => (entry.id !== round.id
|
|
3522
|
-
&& entry.id !== existingRetry?.id
|
|
3523
3796
|
&& (entry.status === "pending" || entry.status === "running")));
|
|
3524
3797
|
if (activeRound !== undefined) {
|
|
3525
3798
|
throw usageError(`Reviewer already has an active review round for this candidate: ${activeRound.id}.`);
|
|
@@ -3536,39 +3809,21 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3536
3809
|
const reviewer = requireRole(tx, task.id, round.reviewerRoleName);
|
|
3537
3810
|
const activePointer = tx.getActiveAgentRun(task.id, reviewer.name);
|
|
3538
3811
|
const activeReviewerRuns = tx.listAgentRuns(task.id).filter((entry) => (entry.roleName === reviewer.name && entry.status === "active"));
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
throw usageError(`${task.id}/${reviewer.name} already has an active run.`);
|
|
3542
|
-
}
|
|
3543
|
-
// A completed identical retry is terminal evidence and is a no-write
|
|
3544
|
-
// idempotent read only when no unrelated Reviewer state is live. Keep the
|
|
3545
|
-
// same fail-closed fences as pending/running retries.
|
|
3546
|
-
if (existingRetry?.status === "completed") {
|
|
3547
|
-
if (activePointer !== null || activeReviewerRuns.length > 0) {
|
|
3548
|
-
throw usageError(`Reviewer Role already has an active run: ${round.reviewerRoleName}.`);
|
|
3549
|
-
}
|
|
3550
|
-
if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
3551
|
-
throw usageError(`Reviewer has unrelated mailbox work: ${round.reviewerRoleName}.`);
|
|
3552
|
-
}
|
|
3553
|
-
return { round: existingRetry, previousRun: run };
|
|
3554
|
-
}
|
|
3555
|
-
if (existingRetry?.status === "pending") {
|
|
3556
|
-
// A pending retry is reusable only while every Reviewer lane is idle.
|
|
3557
|
-
// In particular, never return it to the CLI while another Round/Run or
|
|
3558
|
-
// mailbox batch could cause the CLI to dispatch or fail it.
|
|
3812
|
+
// Issue 06: a completed same-Round retry is a no-write idempotent result.
|
|
3813
|
+
if (round.status === "completed") {
|
|
3559
3814
|
if (activePointer !== null || activeReviewerRuns.length > 0) {
|
|
3560
3815
|
throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
|
|
3561
3816
|
}
|
|
3562
|
-
if (hasMailboxWork(reviewerMailbox)) {
|
|
3563
|
-
throw usageError(`Reviewer
|
|
3564
|
-
}
|
|
3565
|
-
if (hasMailboxWork(runtimeMailbox)) {
|
|
3566
|
-
throw usageError(`Reviewer runtime lifecycle is pending: ${reviewer.name}.`);
|
|
3817
|
+
if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
3818
|
+
throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
|
|
3567
3819
|
}
|
|
3568
|
-
return { round
|
|
3820
|
+
return { round, previousRun: run, created: false };
|
|
3569
3821
|
}
|
|
3570
|
-
|
|
3571
|
-
|
|
3822
|
+
// Issue 06: a running same Round is reusable only with its exact active
|
|
3823
|
+
// Run and mailbox execution. A stranded Run (no active pointer) falls
|
|
3824
|
+
// through and resets the Round after the identity fences below.
|
|
3825
|
+
if (round.status === "running") {
|
|
3826
|
+
const reviewerRunId = round.reviewerRunId;
|
|
3572
3827
|
const activeMatches = reviewerRunId !== undefined
|
|
3573
3828
|
&& activePointer !== null
|
|
3574
3829
|
&& activePointer.id === reviewerRunId
|
|
@@ -3583,12 +3838,24 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3583
3838
|
&& reviewerMailbox?.processing === null
|
|
3584
3839
|
&& reviewerMailbox.pending?.requestCount === 1
|
|
3585
3840
|
&& reviewerMailbox.pending.refs.some((ref) => (isDeepStrictEqual(ref, runRef(task.id, reviewerRunId))));
|
|
3586
|
-
if (
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3841
|
+
if (activePointer !== null
|
|
3842
|
+
&& (!activeMatches
|
|
3843
|
+
|| (!processingMatches && !pendingMatches)
|
|
3844
|
+
|| hasMailboxWork(runtimeMailbox))) {
|
|
3845
|
+
throw usageError(`Existing running ReviewRound ${round.id} lacks its exact active Reviewer execution.`);
|
|
3846
|
+
}
|
|
3847
|
+
if (activeMatches)
|
|
3848
|
+
return { round, previousRun: run, created: false };
|
|
3849
|
+
}
|
|
3850
|
+
// Issue 06: an already-pending Round is the idempotent retry result.
|
|
3851
|
+
if (round.status === "pending") {
|
|
3852
|
+
if (activePointer !== null || activeReviewerRuns.length > 0) {
|
|
3853
|
+
throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
|
|
3590
3854
|
}
|
|
3591
|
-
|
|
3855
|
+
if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
|
|
3856
|
+
throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
|
|
3857
|
+
}
|
|
3858
|
+
return { round, previousRun: run, created: false };
|
|
3592
3859
|
}
|
|
3593
3860
|
if (activePointer !== null || activeReviewerRuns.length > 0) {
|
|
3594
3861
|
throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
|
|
@@ -3599,6 +3866,10 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3599
3866
|
if (runtimeMailbox?.pending !== null && runtimeMailbox?.pending !== undefined) {
|
|
3600
3867
|
throw usageError(`Reviewer runtime lifecycle has pending work: ${reviewer.name}.`);
|
|
3601
3868
|
}
|
|
3869
|
+
const validation = validateExactRunReviewRound(tx, run, { allowTerminal: true });
|
|
3870
|
+
if (validation.disposition !== "applied" || validation.round === null) {
|
|
3871
|
+
throw usageError(`Review Run ${run.id} identity does not match its ReviewRound or frozen Task state changed: ${validation.reason ?? "mismatch"}.`);
|
|
3872
|
+
}
|
|
3602
3873
|
// A stranded pre-delivery Run can leave its exact pending or processing
|
|
3603
3874
|
// dispatch behind; settle only that exact reference while holding the
|
|
3604
3875
|
// same aggregate lock. Any merged or unrelated batch fails closed.
|
|
@@ -3622,27 +3893,31 @@ function retryFailedReviewRun(previous, store, options, now) {
|
|
|
3622
3893
|
}
|
|
3623
3894
|
// Terminalize the old stranded Round only after every identity and mailbox
|
|
3624
3895
|
// fence has passed. The outer transaction rolls back if Round creation fails.
|
|
3896
|
+
let roundToReset = round;
|
|
3625
3897
|
if (round.status !== "failed") {
|
|
3626
3898
|
const summary = round.summary
|
|
3627
3899
|
?? run.summary
|
|
3628
3900
|
?? `Review Run ${run.id} failed before delivery.`;
|
|
3629
|
-
|
|
3901
|
+
roundToReset = finishReviewRound(round, "failed", summary, now, {
|
|
3630
3902
|
report: round.report ?? summary,
|
|
3631
3903
|
checks: round.checks ?? [],
|
|
3632
3904
|
...(round.evidenceCommit === undefined ? {} : { evidenceCommit: round.evidenceCommit })
|
|
3633
|
-
})
|
|
3905
|
+
});
|
|
3906
|
+
tx.saveReviewRound(task.id, roundToReset);
|
|
3634
3907
|
}
|
|
3635
|
-
|
|
3636
|
-
|
|
3908
|
+
// Issue 06: infra retry resets the same semantic Round to pending instead
|
|
3909
|
+
// of manufacturing a new Round, so Round count and finding identity stay
|
|
3910
|
+
// stable across execution-attempt failures.
|
|
3911
|
+
const resetRound = retryTaskReviewRound(roundToReset);
|
|
3912
|
+
tx.saveReviewRound(task.id, resetRound);
|
|
3637
3913
|
recordTaskEvent(tx, task.id, "run.review-retried", {
|
|
3638
3914
|
runId: run.id,
|
|
3639
3915
|
reviewRoundId: round.id,
|
|
3640
|
-
nextReviewRoundId: nextRound.id,
|
|
3641
3916
|
candidateId: candidate.id
|
|
3642
3917
|
}, now);
|
|
3643
|
-
return { round:
|
|
3918
|
+
return { round: resetRound, previousRun: run, created: true };
|
|
3644
3919
|
});
|
|
3645
|
-
return output(result.
|
|
3920
|
+
return output(result.created
|
|
3646
3921
|
? `Review retry requested as ${result.round.id}\n`
|
|
3647
3922
|
: `Review retry already requested as ${result.round.id} (${result.round.status})\n`, { reviewRound: result.round });
|
|
3648
3923
|
}
|
|
@@ -3722,12 +3997,111 @@ function recoverRun(args, store, options) {
|
|
|
3722
3997
|
: "";
|
|
3723
3998
|
return `Recorded exact ${result.action} recovery for ${result.run?.id ?? "unknown Run"}.${followup}\n`;
|
|
3724
3999
|
}
|
|
4000
|
+
/**
|
|
4001
|
+
* Issue 04: builds the terminal yield outcome from the command inputs. The
|
|
4002
|
+
* same construction feeds both the first commit and the idempotent replay, so
|
|
4003
|
+
* a resend always hashes to the same digest.
|
|
4004
|
+
*/
|
|
4005
|
+
function buildYieldOutcome(run, inputSummary, options) {
|
|
4006
|
+
let yieldedReport;
|
|
4007
|
+
if (run.purpose === "review"
|
|
4008
|
+
|| (run.purpose === "execution" && run.executionGroupId !== undefined)) {
|
|
4009
|
+
yieldedReport = parseReviewYieldReport(inputSummary);
|
|
4010
|
+
}
|
|
4011
|
+
const summary = yieldedReport?.summary ?? inputSummary;
|
|
4012
|
+
if (yieldedReport === undefined)
|
|
4013
|
+
return { summary };
|
|
4014
|
+
return {
|
|
4015
|
+
summary,
|
|
4016
|
+
reviewResult: {
|
|
4017
|
+
report: yieldedReport.report,
|
|
4018
|
+
checks: yieldedReport.checks,
|
|
4019
|
+
...(yieldedReport.findings === undefined ? {} : { findings: yieldedReport.findings }),
|
|
4020
|
+
...(yieldedReport.evidence === undefined ? {} : { evidence: yieldedReport.evidence }),
|
|
4021
|
+
...(run.purpose === "review"
|
|
4022
|
+
&& options.reviewWorkspaceResult?.evidenceCommit === undefined
|
|
4023
|
+
? {}
|
|
4024
|
+
: run.purpose === "review"
|
|
4025
|
+
? { evidenceCommit: options.reviewWorkspaceResult.evidenceCommit }
|
|
4026
|
+
: yieldedReport.evidenceCommit === undefined
|
|
4027
|
+
? {}
|
|
4028
|
+
: { evidenceCommit: yieldedReport.evidenceCommit }),
|
|
4029
|
+
...(options.executionLaneGitSnapshot === undefined
|
|
4030
|
+
|| options.executionLaneGitSnapshot === null
|
|
4031
|
+
? {}
|
|
4032
|
+
: { gitSnapshot: options.executionLaneGitSnapshot })
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Issue 04: replays an already-committed yield. Returns the committed receipt
|
|
4038
|
+
* for the same outcome, fails closed for a different outcome, or returns
|
|
4039
|
+
* `null` to keep the legacy "already terminal" behavior.
|
|
4040
|
+
*/
|
|
4041
|
+
function replayYieldReceipt(run, inputSummary, options) {
|
|
4042
|
+
const config = providerRetryConfig(options.environment ?? process.env);
|
|
4043
|
+
if (!config.yieldReceiptReplay)
|
|
4044
|
+
return null;
|
|
4045
|
+
if (run.yieldReceipt === undefined)
|
|
4046
|
+
return null;
|
|
4047
|
+
const outcome = buildYieldOutcome(run, inputSummary, options);
|
|
4048
|
+
const match = matchYieldReceipt(run.yieldReceipt, {
|
|
4049
|
+
status: "yielded",
|
|
4050
|
+
summary: outcome.summary,
|
|
4051
|
+
...(outcome.reviewResult === undefined ? {} : { reviewResult: outcome.reviewResult })
|
|
4052
|
+
});
|
|
4053
|
+
if (match === null)
|
|
4054
|
+
return null;
|
|
4055
|
+
if (match.kind === "digest-mismatch") {
|
|
4056
|
+
throw usageError(`Run ${run.id} is already terminal with a different yield outcome. `
|
|
4057
|
+
+ `Existing receipt: ${match.existing.receiptId} (request ${match.existing.requestId}).`);
|
|
4058
|
+
}
|
|
4059
|
+
return output(`Run ${run.id} yield already committed.\n`
|
|
4060
|
+
+ `Receipt: ${match.receipt.receiptId}\n`
|
|
4061
|
+
+ `Request: ${match.receipt.requestId}\n`, { receipt: match.receipt });
|
|
4062
|
+
}
|
|
4063
|
+
/**
|
|
4064
|
+
* Issue 04: `yui task run yield-status <task>/<run>` — returns the committed
|
|
4065
|
+
* yield receipt for a terminal Run, or the current status for an active Run.
|
|
4066
|
+
*/
|
|
4067
|
+
function yieldRunStatus(args, store, options) {
|
|
4068
|
+
const usage = "Task run yield-status usage: yui task run yield-status <task>/<run>.";
|
|
4069
|
+
const parsed = parseTail(args, new Set(), usage);
|
|
4070
|
+
exactPositionals(parsed.positionals, 1, usage);
|
|
4071
|
+
const run = requireRun(store, parsed.positionals[0], options);
|
|
4072
|
+
if (run.status === "active") {
|
|
4073
|
+
return output(`Run ${run.id} is active; no yield receipt yet.\n`, {
|
|
4074
|
+
runId: run.id,
|
|
4075
|
+
status: run.status
|
|
4076
|
+
});
|
|
4077
|
+
}
|
|
4078
|
+
if (run.yieldReceipt === undefined) {
|
|
4079
|
+
return output(`Run ${run.id} is ${run.status}; no yield receipt recorded.\n`, {
|
|
4080
|
+
runId: run.id,
|
|
4081
|
+
status: run.status
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
return output(`Run ${run.id} yield receipt:\n`
|
|
4085
|
+
+ `Receipt: ${run.yieldReceipt.receiptId}\n`
|
|
4086
|
+
+ `Request: ${run.yieldReceipt.requestId}\n`
|
|
4087
|
+
+ `Committed: ${run.yieldReceipt.committedAt}\n`, { receipt: run.yieldReceipt });
|
|
4088
|
+
}
|
|
3725
4089
|
function yieldRun(args, store, options) {
|
|
3726
4090
|
const usage = "Task run yield usage: yui task run yield <task>/<run> (--summary <text>|--summary-file <path|->).";
|
|
3727
4091
|
const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage);
|
|
3728
4092
|
exactPositionals(parsed.positionals, 1, usage);
|
|
3729
4093
|
const inputSummary = readCommandText(parsed.options.get("--summary"), parsed.options.get("--summary-file"), "--summary", usage);
|
|
3730
4094
|
const now = clock(options);
|
|
4095
|
+
// Issue 04: an already-terminal Run may be a lost-response resend. Match
|
|
4096
|
+
// the presented outcome against the committed receipt before opening the
|
|
4097
|
+
// transaction; the receipt is immutable once committed.
|
|
4098
|
+
const existing = requireRun(store, parsed.positionals[0], options);
|
|
4099
|
+
if (existing.status !== "active") {
|
|
4100
|
+
const replayed = replayYieldReceipt(existing, inputSummary, options);
|
|
4101
|
+
if (replayed !== null)
|
|
4102
|
+
return replayed;
|
|
4103
|
+
throw usageError(`Run ${existing.id} is already terminal: ${existing.status}.`);
|
|
4104
|
+
}
|
|
3731
4105
|
const yielded = store.transaction((tx) => {
|
|
3732
4106
|
const active = requireRun(tx, parsed.positionals[0], options);
|
|
3733
4107
|
if (active.status !== "active") {
|
|
@@ -3775,7 +4149,8 @@ function yieldRun(args, store, options) {
|
|
|
3775
4149
|
throw usageError(`Reported Review evidence commit does not match the managed workspace: ${active.id}.`);
|
|
3776
4150
|
}
|
|
3777
4151
|
}
|
|
3778
|
-
const
|
|
4152
|
+
const yieldOutcome = buildYieldOutcome(active, inputSummary, options);
|
|
4153
|
+
const summary = yieldOutcome.summary;
|
|
3779
4154
|
const terminalization = terminalizeExactTaskRun(tx, {
|
|
3780
4155
|
taskId: task.id,
|
|
3781
4156
|
roleName: role.name,
|
|
@@ -3789,28 +4164,9 @@ function yieldRun(args, store, options) {
|
|
|
3789
4164
|
? {}
|
|
3790
4165
|
: { launchId: options.environment.YUI_LAUNCH_ID }),
|
|
3791
4166
|
outcome: { status: "yielded", summary },
|
|
3792
|
-
...(
|
|
4167
|
+
...(yieldOutcome.reviewResult === undefined
|
|
3793
4168
|
? {}
|
|
3794
|
-
: {
|
|
3795
|
-
reviewResult: {
|
|
3796
|
-
report: yieldedReport.report,
|
|
3797
|
-
checks: yieldedReport.checks,
|
|
3798
|
-
...(yieldedReport.findings === undefined ? {} : { findings: yieldedReport.findings }),
|
|
3799
|
-
...(yieldedReport.evidence === undefined ? {} : { evidence: yieldedReport.evidence }),
|
|
3800
|
-
...(active.purpose === "review"
|
|
3801
|
-
&& options.reviewWorkspaceResult?.evidenceCommit === undefined
|
|
3802
|
-
? {}
|
|
3803
|
-
: active.purpose === "review"
|
|
3804
|
-
? { evidenceCommit: options.reviewWorkspaceResult.evidenceCommit }
|
|
3805
|
-
: yieldedReport.evidenceCommit === undefined
|
|
3806
|
-
? {}
|
|
3807
|
-
: { evidenceCommit: yieldedReport.evidenceCommit }),
|
|
3808
|
-
...(options.executionLaneGitSnapshot === undefined
|
|
3809
|
-
|| options.executionLaneGitSnapshot === null
|
|
3810
|
-
? {}
|
|
3811
|
-
: { gitSnapshot: options.executionLaneGitSnapshot })
|
|
3812
|
-
}
|
|
3813
|
-
})
|
|
4169
|
+
: { reviewResult: yieldOutcome.reviewResult })
|
|
3814
4170
|
}, now);
|
|
3815
4171
|
if (terminalization.disposition !== "applied" || terminalization.run === null) {
|
|
3816
4172
|
throw usageError(`Run ${active.id} no longer matches its exact execution fence: `
|
|
@@ -3978,6 +4334,21 @@ function yieldRun(args, store, options) {
|
|
|
3978
4334
|
...(terminal.workItemId === undefined ? [] : [workItemRef(task.id, terminal.workItemId)])
|
|
3979
4335
|
]);
|
|
3980
4336
|
}
|
|
4337
|
+
// Issue 05: record the Leader's terminal receipt so the Scheduler can
|
|
4338
|
+
// suppress no-change `task-orphaned` wakes. The disposition and digest are
|
|
4339
|
+
// machine-derived from the post-yield projection; the Leader does not
|
|
4340
|
+
// need to cooperate for the admission check to work.
|
|
4341
|
+
if (role.name === LEADER_ROLE) {
|
|
4342
|
+
const receipt = leaderYieldReceipt(tx, task, terminal, now);
|
|
4343
|
+
if (receipt !== null) {
|
|
4344
|
+
tx.saveAgentRun(receipt);
|
|
4345
|
+
return {
|
|
4346
|
+
run: receipt,
|
|
4347
|
+
reviewDispatch,
|
|
4348
|
+
notifyLeader: leaderHandoff !== null
|
|
4349
|
+
};
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
3981
4352
|
return {
|
|
3982
4353
|
run: terminal,
|
|
3983
4354
|
reviewDispatch,
|
|
@@ -3998,6 +4369,47 @@ function yieldRun(args, store, options) {
|
|
|
3998
4369
|
: { reviewRound: yielded.reviewDispatch.round })
|
|
3999
4370
|
});
|
|
4000
4371
|
}
|
|
4372
|
+
/**
|
|
4373
|
+
* Issue 05: compute the Leader Run terminal receipt (disposition + observed
|
|
4374
|
+
* actionability digest) from the post-yield projection. Returns the updated
|
|
4375
|
+
* Run, or null when the receipt cannot be computed (the caller keeps the
|
|
4376
|
+
* unmodified terminal Run in that case; the Scheduler fails open).
|
|
4377
|
+
*/
|
|
4378
|
+
function leaderYieldReceipt(tx, task, terminal, now) {
|
|
4379
|
+
try {
|
|
4380
|
+
const projection = buildTaskExecutionProjection(tx, task.id, task);
|
|
4381
|
+
if (projection === null)
|
|
4382
|
+
return null;
|
|
4383
|
+
const disposition = deriveLeaderRunDisposition(projection.status, task.status);
|
|
4384
|
+
const digest = computeActionabilityDigest(collectTaskActionability(tx, task.id));
|
|
4385
|
+
const waitReason = disposition === "waiting" || disposition === "blocked"
|
|
4386
|
+
? leaderWaitReason(projection)
|
|
4387
|
+
: undefined;
|
|
4388
|
+
return {
|
|
4389
|
+
...terminal,
|
|
4390
|
+
disposition,
|
|
4391
|
+
observedActionabilityDigest: digest,
|
|
4392
|
+
...(waitReason === undefined ? {} : { waitReason }),
|
|
4393
|
+
updatedAt: now.toISOString()
|
|
4394
|
+
};
|
|
4395
|
+
}
|
|
4396
|
+
catch {
|
|
4397
|
+
return null;
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
function leaderWaitReason(projection) {
|
|
4401
|
+
const blocker = projection.blockers[0];
|
|
4402
|
+
if (blocker !== undefined) {
|
|
4403
|
+
return { kind: blocker.kind, ref: blocker.id };
|
|
4404
|
+
}
|
|
4405
|
+
if (projection.status === "waiting-on-agents") {
|
|
4406
|
+
return { kind: "delegated-work" };
|
|
4407
|
+
}
|
|
4408
|
+
if (projection.status === "waiting-user") {
|
|
4409
|
+
return { kind: "input" };
|
|
4410
|
+
}
|
|
4411
|
+
return undefined;
|
|
4412
|
+
}
|
|
4001
4413
|
/**
|
|
4002
4414
|
* Records a structured progress checkpoint for an active Run. This is a durable
|
|
4003
4415
|
* run fact, not a Task Message: it advances the Run's durable-progress clock so
|
|
@@ -4222,6 +4634,9 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
|
|
|
4222
4634
|
.map(({ projectId, commit }) => `${projectId}@${commit}`)
|
|
4223
4635
|
.join(", ")
|
|
4224
4636
|
: `candidate@${round.reviewBaseCommit}`;
|
|
4637
|
+
const findingContext = taskScope
|
|
4638
|
+
? buildTaskFinalReviewFindingContext(tx, taskId, round.taskCandidate).context
|
|
4639
|
+
: "";
|
|
4225
4640
|
const scopeLabel = taskScope ? "Task-final" : "WorkItem";
|
|
4226
4641
|
const projectPolicyPointers = task.projectBindings
|
|
4227
4642
|
.map(({ projectId }) => (`yui project show ${projectId}; yui project knowledge list ${projectId}`))
|
|
@@ -4238,6 +4653,7 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
|
|
|
4238
4653
|
`Review workspace source: exact workspace attached to this Reviewer Lane`,
|
|
4239
4654
|
`Candidate summary: ${candidate.summary}`,
|
|
4240
4655
|
`Acceptance criteria: ${item.acceptance.length === 0 ? "none" : item.acceptance.join("; ")}`,
|
|
4656
|
+
...(taskScope ? [findingContext] : []),
|
|
4241
4657
|
"Start from the user's core outcome and the WorkItem intent. The candidate summary is a pointer, not proof: inspect the complete relevant change, callers, and proportionate checks.",
|
|
4242
4658
|
"Keep Yui Core lifecycle safety, generic Reviewer behavior, Project Policy/Knowledge, and the Task Contract separate. Follow Project Policy pointers from the dispatch context for project-specific checks.",
|
|
4243
4659
|
...(round.scope === "task"
|