@zq-silk/yui 0.8.2 → 0.8.3
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 +22 -0
- package/dist/cli/commandCatalog.js +10 -5
- package/dist/cli.js +33 -6
- package/dist/commands/taskCommands.js +350 -24
- package/dist/commands/taskCompletionGate.js +152 -0
- package/dist/context/runContextPack.js +19 -4
- package/dist/context/sessionBootstrapManifest.js +1 -1
- package/dist/repository/gitWorkspace.js +7 -4
- package/dist/repository/taskBaseFreshness.js +4 -2
- package/dist/run/agentRun.js +2 -2
- package/dist/scheduler/wakeReason.js +1 -0
- package/dist/storage/sqliteStore.js +2 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/task/completionReadiness.js +43 -0
- package/dist/task/nextAction.js +6 -4
- package/dist/task/publicationReference.js +1 -0
- package/dist/workItem/workItem.js +12 -0
- package/dist/workspace/workItemChangeSetManager.js +2 -1
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -3
- package/skills/yui-runtime/SKILL.md +7 -2
package/README.md
CHANGED
|
@@ -659,6 +659,28 @@ yui task complete <task-id> --summary-file delivery.txt --refresh-remote
|
|
|
659
659
|
yui task reopen <task-id>
|
|
660
660
|
```
|
|
661
661
|
|
|
662
|
+
When a verified squash-merge Publication records a remote commit that is
|
|
663
|
+
ancestry-divergent from the unchanged, frozen Task-final Review head, a user or
|
|
664
|
+
global Operator may explicitly authorize completion against its identical Git
|
|
665
|
+
tree:
|
|
666
|
+
|
|
667
|
+
```sh
|
|
668
|
+
yui task complete <task-id> --summary-file delivery.txt \
|
|
669
|
+
--accept-published-tree <publication-id>
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
This does not weaken normal freshness checks. Yui requires the current,
|
|
673
|
+
unsuperseded Publication to be merged and verified, its local commit to equal
|
|
674
|
+
the reviewed physical Task head, its remote commit to be ancestry-divergent,
|
|
675
|
+
and both commits to resolve to the same exact tree. `--refresh-remote` fetches
|
|
676
|
+
the remote object graph before resolving that Publication commit. For a Task
|
|
677
|
+
governed by a durable exact final-review contract, the user/Operator command
|
|
678
|
+
persists the exact authorization tuple and wakes the Task Leader; only that
|
|
679
|
+
contract-capable Leader may consume it and complete the Task. Tasks without
|
|
680
|
+
that contract retain the one-step explicit completion path. The Task event
|
|
681
|
+
audit records the authorization and, on completion, the accepted Project,
|
|
682
|
+
Publication, ReviewRound, both commits, and tree.
|
|
683
|
+
|
|
662
684
|
Completed Tasks reject messages, dispatch, Provider authority changes, retry, and late yields until explicitly reopened, while retaining Task main for inspection or integration. Every isolated WorkItem worktree must be explicitly cleaned as integrated or abandoned before archive; that cleanup also removes its managed branch. Archive requires `--integrated` or `--abandon` to state the Task main outcome and is allowed only after Task main is clean. It removes managed worktrees but retains Task and WorkItem records. The Task main branch is retained as a recovery artifact instead of being silently deleted.
|
|
663
685
|
Task lifecycle completion/selection only suggests valid source states: Draft for activate, active for complete, and completed for reopen.
|
|
664
686
|
|
|
@@ -277,8 +277,8 @@ const taskChildren = [
|
|
|
277
277
|
{
|
|
278
278
|
name: "complete",
|
|
279
279
|
summary: "Complete an active Task and stop automatic wakeups.",
|
|
280
|
-
usage: "yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote]",
|
|
281
|
-
options: ["--summary", "--summary-file", "--refresh-remote"],
|
|
280
|
+
usage: "yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote] [--accept-published-tree <publication-id>]",
|
|
281
|
+
options: ["--summary", "--summary-file", "--refresh-remote", "--accept-published-tree"],
|
|
282
282
|
fileOptions: ["--summary-file"]
|
|
283
283
|
},
|
|
284
284
|
{
|
|
@@ -727,8 +727,8 @@ const taskChildren = [
|
|
|
727
727
|
{
|
|
728
728
|
name: "expand",
|
|
729
729
|
summary: "Expand one authorized Run context reference.",
|
|
730
|
-
usage: "yui task run context expand <task>/<run> <ref-id> [--mode full]",
|
|
731
|
-
options: ["--mode"]
|
|
730
|
+
usage: "yui task run context expand <task>/<run> <ref-id> [--store <store>] [--mode full]",
|
|
731
|
+
options: ["--store", "--mode"]
|
|
732
732
|
},
|
|
733
733
|
{
|
|
734
734
|
name: "delta",
|
|
@@ -751,7 +751,7 @@ const taskChildren = [
|
|
|
751
751
|
{
|
|
752
752
|
name: "review",
|
|
753
753
|
summary: "Control Task-final ReviewRounds.",
|
|
754
|
-
sections: [{ id: "manage", title: "Commands", entries: ["request", "group", "retry", "finding"] }],
|
|
754
|
+
sections: [{ id: "manage", title: "Commands", entries: ["request", "force-fresh", "group", "retry", "finding"] }],
|
|
755
755
|
children: [
|
|
756
756
|
{
|
|
757
757
|
name: "request",
|
|
@@ -759,6 +759,11 @@ const taskChildren = [
|
|
|
759
759
|
usage: "yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...] [--delta-recheck]",
|
|
760
760
|
options: ["--role", "--strategy", "--lane-role", "--delta-recheck"]
|
|
761
761
|
},
|
|
762
|
+
{
|
|
763
|
+
name: "force-fresh",
|
|
764
|
+
summary: "Replace one exact non-semantic failed Task-final Review with a distinct full Round.",
|
|
765
|
+
usage: "yui task review force-fresh <task>/<review-round>"
|
|
766
|
+
},
|
|
762
767
|
{
|
|
763
768
|
name: "group",
|
|
764
769
|
summary: "Resolve a Reviewer ExecutionGroup after its Lanes finish.",
|
package/dist/cli.js
CHANGED
|
@@ -48,7 +48,7 @@ import { createUpdatePorts } from "./cli/updatePorts.js";
|
|
|
48
48
|
import { createReleaseWorkflowPorts } from "./release/releaseWorkflowPorts.js";
|
|
49
49
|
import { readRuntimeIdentity } from "./release/runtimeRelease.js";
|
|
50
50
|
import { renderReleaseActivateResult, renderReleaseInstallResult, renderReleaseList, runReleaseActivate, runReleaseInstall, runReleaseList } from "./commands/releaseCommands.js";
|
|
51
|
-
import { reconcileTaskRemoteBaselines } from "./commands/taskCompletionGate.js";
|
|
51
|
+
import { reconcileTaskRemoteBaselines, verifyTaskCompletionPublishedTree } from "./commands/taskCompletionGate.js";
|
|
52
52
|
import { runTaskBaseStatusCommand } from "./commands/taskBaseCommands.js";
|
|
53
53
|
import { assertTaskBaseFreshnessForCompletion, inspectTaskBaseFreshness } from "./repository/taskBaseFreshness.js";
|
|
54
54
|
import { FileCompletionManager, resolveCliIdentity } from "./completion/fileCompletionManager.js";
|
|
@@ -944,6 +944,7 @@ export async function main() {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
let completionSummary;
|
|
947
|
+
let completionPublishedTreeProof;
|
|
947
948
|
if (resolved[1] === "base" && resolved[2] === "status") {
|
|
948
949
|
const result = await runTaskBaseStatusCommand(resolved.slice(3), store);
|
|
949
950
|
emit(result.output, false, result.data);
|
|
@@ -958,12 +959,26 @@ export async function main() {
|
|
|
958
959
|
...(taskFinalReviewContract === undefined
|
|
959
960
|
? {}
|
|
960
961
|
: { taskFinalReviewContract })
|
|
961
|
-
});
|
|
962
|
+
}, completionRequest);
|
|
962
963
|
if (!completion.completed && !completion.activeTaskReview) {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
964
|
+
// An explicit refresh must fetch the remote object graph before the
|
|
965
|
+
// Publication proof resolves its exact commit. Without the flag the
|
|
966
|
+
// command remains offline and preserves the existing proof-first path.
|
|
967
|
+
const refreshedFreshness = refreshRemote
|
|
968
|
+
? await inspectTaskBaseFreshness(resolved[2], store, { refresh: true })
|
|
969
|
+
: undefined;
|
|
970
|
+
if (completionRequest.acceptedPublishedTreePublicationId !== undefined) {
|
|
971
|
+
completionPublishedTreeProof = await verifyTaskCompletionPublishedTree(completionRequest.taskId, completionRequest.acceptedPublishedTreePublicationId, store);
|
|
972
|
+
}
|
|
973
|
+
const freshness = refreshedFreshness
|
|
974
|
+
?? await inspectTaskBaseFreshness(resolved[2], store);
|
|
975
|
+
for (const warning of assertTaskBaseFreshnessForCompletion(freshness, {
|
|
976
|
+
...(completionPublishedTreeProof === undefined
|
|
977
|
+
? {}
|
|
978
|
+
: {
|
|
979
|
+
acceptedPublishedTreeProjectId: completionPublishedTreeProof.projectId
|
|
980
|
+
})
|
|
981
|
+
})) {
|
|
967
982
|
process.stderr.write(`Warning: ${warning}\n`);
|
|
968
983
|
}
|
|
969
984
|
// Keep completion offline by default. An explicit refresh is the only
|
|
@@ -1002,6 +1017,9 @@ export async function main() {
|
|
|
1002
1017
|
? {}
|
|
1003
1018
|
: { taskFinalReviewContract }),
|
|
1004
1019
|
...(completionSummary === undefined ? {} : { completionSummary }),
|
|
1020
|
+
...(completionPublishedTreeProof === undefined
|
|
1021
|
+
? {}
|
|
1022
|
+
: { completionPublishedTreeProof }),
|
|
1005
1023
|
...(workItemIntegrationProof === undefined ? {} : { workItemIntegrationProof }),
|
|
1006
1024
|
...(candidateGitSnapshot === undefined ? {} : { candidateGitSnapshot }),
|
|
1007
1025
|
...(candidateMaterialization === undefined
|
|
@@ -1673,6 +1691,15 @@ async function actualTaskReviewCandidateForTaskCommand(args, store, preparer, en
|
|
|
1673
1691
|
taskId = reference.taskId;
|
|
1674
1692
|
}
|
|
1675
1693
|
}
|
|
1694
|
+
else if (args[1] === "review"
|
|
1695
|
+
&& args[2] === "force-fresh"
|
|
1696
|
+
&& args[3] !== undefined) {
|
|
1697
|
+
const reference = cliTaskRecordReference(args[3], "reviewRound", environment);
|
|
1698
|
+
const round = store.getReviewRound(reference.taskId, reference.localId);
|
|
1699
|
+
if (round !== null && (round.scope ?? "work-item") === "task") {
|
|
1700
|
+
taskId = reference.taskId;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1676
1703
|
else if (args[1] === "work"
|
|
1677
1704
|
&& args[2] === "review"
|
|
1678
1705
|
&& args[3] === "retry"
|
|
@@ -7,6 +7,7 @@ import { CliError, dataError, roleNotFound, runtimeError, taskNotFound, usageErr
|
|
|
7
7
|
import { createTaskEvent } from "../event/taskEvent.js";
|
|
8
8
|
import { clearMatchingLeaderStallAttention, isRoleRunStalled, RUN_PROGRESS_EVENT, RUN_RECOVERED_EVENT } from "../scheduler/roleRunStall.js";
|
|
9
9
|
import { readCommandText } from "./textInput.js";
|
|
10
|
+
import { assertTaskCompletionPublishedTreeProof } from "./taskCompletionGate.js";
|
|
10
11
|
import { createRoleSessionSet, roleAgentSessionResumeMode, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
|
|
11
12
|
import { currentProviderActivation, transferProviderAuthority } from "../runtime/providerRuntimeIdentity.js";
|
|
12
13
|
import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
|
|
@@ -36,10 +37,11 @@ import { blockingProviderContinuations, projectProviderContinuations } from "../
|
|
|
36
37
|
import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
|
|
37
38
|
import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, updateTaskMetadata } from "../task/task.js";
|
|
38
39
|
import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
|
|
40
|
+
import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
|
|
39
41
|
import { projectCompletionReadiness } from "../task/completionReadiness.js";
|
|
40
42
|
import { resolveProject } from "../repository/project.js";
|
|
41
43
|
import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
|
|
42
|
-
import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
44
|
+
import { currentWorkItemCandidate, governingWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
43
45
|
import { addExecutionLane, createExecutionGroup, resolveExecutionGroup, restartExecutionLane, updateExecutionLane } from "../execution/executionGroup.js";
|
|
44
46
|
import { sameTaskFinalReviewContract, taskFinalReviewConfig, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
45
47
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
@@ -80,8 +82,12 @@ function legacyWorkItemReviewConfig(config) {
|
|
|
80
82
|
}
|
|
81
83
|
function storedTaskFinalReviewContract(store, taskId) {
|
|
82
84
|
const contracts = store.listWorkItems(taskId)
|
|
83
|
-
.flatMap((
|
|
84
|
-
|
|
85
|
+
.flatMap((item) => {
|
|
86
|
+
const candidate = governingWorkItemCandidate(item);
|
|
87
|
+
return candidate?.taskFinalReviewContract === undefined
|
|
88
|
+
? []
|
|
89
|
+
: [candidate.taskFinalReviewContract];
|
|
90
|
+
});
|
|
85
91
|
const first = contracts[0];
|
|
86
92
|
if (first === undefined)
|
|
87
93
|
return undefined;
|
|
@@ -97,7 +103,7 @@ function storedTaskFinalReviewContract(store, taskId) {
|
|
|
97
103
|
* acceptance, and completion mutation must present the same verified
|
|
98
104
|
* capability; shared review-config drift is intentionally irrelevant.
|
|
99
105
|
*/
|
|
100
|
-
function taskFinalReviewContractForMutation(store, taskId, options) {
|
|
106
|
+
function taskFinalReviewContractForMutation(store, taskId, options, authorization = {}) {
|
|
101
107
|
const supplied = options.taskFinalReviewContract;
|
|
102
108
|
if (supplied !== undefined) {
|
|
103
109
|
validateTaskFinalReviewContract(supplied);
|
|
@@ -113,6 +119,8 @@ function taskFinalReviewContractForMutation(store, taskId, options) {
|
|
|
113
119
|
if (stored === undefined)
|
|
114
120
|
return supplied;
|
|
115
121
|
if (supplied === undefined) {
|
|
122
|
+
if (authorization.allowStoredWithoutSupplied === true)
|
|
123
|
+
return stored;
|
|
116
124
|
throw usageError(`Task final-review contract is missing for ${taskId}.`);
|
|
117
125
|
}
|
|
118
126
|
if (!sameTaskFinalReviewContract(stored, supplied)) {
|
|
@@ -121,8 +129,8 @@ function taskFinalReviewContractForMutation(store, taskId, options) {
|
|
|
121
129
|
return stored;
|
|
122
130
|
}
|
|
123
131
|
export function parseTaskCompletionRequest(args, summaryOverride) {
|
|
124
|
-
const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote].";
|
|
125
|
-
const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage, new Set(["--refresh-remote"]));
|
|
132
|
+
const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote] [--accept-published-tree <publication-id>].";
|
|
133
|
+
const parsed = parseTail(args, new Set(["--summary", "--summary-file", "--accept-published-tree"]), usage, new Set(["--refresh-remote"]));
|
|
126
134
|
exactPositionals(parsed.positionals, 1, usage);
|
|
127
135
|
const inlineSummary = parsed.options.get("--summary");
|
|
128
136
|
const summaryFile = parsed.options.get("--summary-file");
|
|
@@ -130,7 +138,14 @@ export function parseTaskCompletionRequest(args, summaryOverride) {
|
|
|
130
138
|
throw usageError(`Specify exactly one of --summary or --summary-file.`, usage);
|
|
131
139
|
}
|
|
132
140
|
const summary = summaryOverride ?? readCommandText(inlineSummary, summaryFile, "--summary", usage);
|
|
133
|
-
|
|
141
|
+
const acceptedPublishedTreePublicationId = parsed.options.get("--accept-published-tree");
|
|
142
|
+
return {
|
|
143
|
+
taskId: parsed.positionals[0],
|
|
144
|
+
summary,
|
|
145
|
+
...(acceptedPublishedTreePublicationId === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { acceptedPublishedTreePublicationId })
|
|
148
|
+
};
|
|
134
149
|
}
|
|
135
150
|
/**
|
|
136
151
|
* Check every read-only completion blocker before a caller resolves remote
|
|
@@ -144,7 +159,7 @@ export function parseTaskCompletionRequest(args, summaryOverride) {
|
|
|
144
159
|
* terminalization precondition before attempting completion. All blockers
|
|
145
160
|
* are reported in one error instead of one per attempt.
|
|
146
161
|
*/
|
|
147
|
-
export function preflightTaskCompletion(taskId, store, options = {}) {
|
|
162
|
+
export function preflightTaskCompletion(taskId, store, options = {}, request = {}) {
|
|
148
163
|
const task = requireTask(store, taskId);
|
|
149
164
|
const actor = taskActor(options, task.id);
|
|
150
165
|
if (task.status === "completed") {
|
|
@@ -157,7 +172,15 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
|
|
|
157
172
|
// Resolve and authenticate the durable Task-local gate before any remote
|
|
158
173
|
// fetch or Integration write. All checks below mirror the transactional
|
|
159
174
|
// completion path, which remains the final CAS fence after reconciliation.
|
|
160
|
-
|
|
175
|
+
// A human/global Operator cannot present the exact managed Leader contract.
|
|
176
|
+
// For the explicit published-tree path only, let that caller authenticate
|
|
177
|
+
// the stored contract far enough to persist an exact authorization fact.
|
|
178
|
+
// The same command must return before any contract-governed completion
|
|
179
|
+
// mutation; the exact Leader later consumes the authorization with the real
|
|
180
|
+
// contract capability.
|
|
181
|
+
const authorizingPublishedTree = request.acceptedPublishedTreePublicationId !== undefined
|
|
182
|
+
&& actor !== "leader";
|
|
183
|
+
const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options, { allowStoredWithoutSupplied: authorizingPublishedTree });
|
|
161
184
|
const activeTaskReview = store.listReviewRounds(task.id).some((round) => ((round.scope ?? "work-item") === "task"
|
|
162
185
|
&& (round.status === "pending" || round.status === "running")));
|
|
163
186
|
// Issue 06: one shared readiness projection enumerates every blocker.
|
|
@@ -647,17 +670,42 @@ function completeTaskCommand(args, store, options) {
|
|
|
647
670
|
const summary = request.summary;
|
|
648
671
|
const now = clock(options);
|
|
649
672
|
const result = store.transaction((tx) => {
|
|
650
|
-
const preflight = preflightTaskCompletion(request.taskId, tx, options);
|
|
673
|
+
const preflight = preflightTaskCompletion(request.taskId, tx, options, request);
|
|
651
674
|
const { task, actor } = preflight;
|
|
652
675
|
if (preflight.completed) {
|
|
653
676
|
return {
|
|
654
677
|
task,
|
|
655
678
|
changed: false,
|
|
656
679
|
runtimeCleanupTargets: [],
|
|
657
|
-
finalReview: undefined
|
|
680
|
+
finalReview: undefined,
|
|
681
|
+
publishedTreeAuthorization: undefined
|
|
658
682
|
};
|
|
659
683
|
}
|
|
660
684
|
const taskFinalContract = preflight.taskFinalReviewContract;
|
|
685
|
+
const publishedTreeProof = request.acceptedPublishedTreePublicationId === undefined
|
|
686
|
+
? undefined
|
|
687
|
+
: assertTaskCompletionPublishedTreeProof(tx, task, request.acceptedPublishedTreePublicationId, options.completionPublishedTreeProof, actualTaskReviewCandidateForMutation(tx, task, options));
|
|
688
|
+
const requiresContractHandoff = publishedTreeProof !== undefined
|
|
689
|
+
&& taskFinalContract !== undefined;
|
|
690
|
+
if (requiresContractHandoff && actor !== "leader") {
|
|
691
|
+
const existing = matchingPublishedTreeAuthorization(tx, publishedTreeProof);
|
|
692
|
+
const event = existing ?? recordTaskEventRecord(tx, task.id, TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT, publishedTreeAuthorizationPayload(actor, publishedTreeProof), now);
|
|
693
|
+
enqueueWork(tx, leaderMailbox(task.id), wakeReason("published-tree-authorized", event.id), now, [eventRef(task.id, event.id)]);
|
|
694
|
+
return {
|
|
695
|
+
task,
|
|
696
|
+
changed: false,
|
|
697
|
+
runtimeCleanupTargets: [],
|
|
698
|
+
finalReview: undefined,
|
|
699
|
+
publishedTreeAuthorization: {
|
|
700
|
+
event,
|
|
701
|
+
proof: publishedTreeProof,
|
|
702
|
+
created: existing === undefined
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
if (publishedTreeProof !== undefined && actor === "leader") {
|
|
707
|
+
requirePublishedTreeAuthorization(tx, publishedTreeProof);
|
|
708
|
+
}
|
|
661
709
|
const roles = tx.listRoles(task.id);
|
|
662
710
|
const activeRuns = roles
|
|
663
711
|
.map((role) => ({ role, run: tx.getActiveAgentRun(task.id, role.name) }))
|
|
@@ -706,7 +754,8 @@ function completeTaskCommand(args, store, options) {
|
|
|
706
754
|
runtimeCleanupTargets: [],
|
|
707
755
|
finalReview,
|
|
708
756
|
resumedPendingFinalReview: pendingFinalReviewIds.has(finalReview.id),
|
|
709
|
-
terminalizedLeaderRun
|
|
757
|
+
terminalizedLeaderRun,
|
|
758
|
+
publishedTreeAuthorization: undefined
|
|
710
759
|
};
|
|
711
760
|
}
|
|
712
761
|
// Issue 06: re-validate the full completion readiness inside the
|
|
@@ -729,6 +778,17 @@ function completeTaskCommand(args, store, options) {
|
|
|
729
778
|
tx.clearOperatorNotification(task.id);
|
|
730
779
|
tx.saveOperatorNotification(createTaskTerminalNotification(task.id, "completed", actor, summary, now));
|
|
731
780
|
enqueueWork(tx, { kind: "operator" }, "task-terminal", now, [taskRef(task.id)]);
|
|
781
|
+
if (publishedTreeProof !== undefined) {
|
|
782
|
+
recordTaskEvent(tx, task.id, "task.completion-published-tree-accepted", {
|
|
783
|
+
by: actor,
|
|
784
|
+
projectId: publishedTreeProof.projectId,
|
|
785
|
+
publicationId: publishedTreeProof.publicationId,
|
|
786
|
+
reviewRoundId: publishedTreeProof.reviewRoundId,
|
|
787
|
+
localCommit: publishedTreeProof.localCommit,
|
|
788
|
+
remoteCommit: publishedTreeProof.remoteCommit,
|
|
789
|
+
tree: publishedTreeProof.tree
|
|
790
|
+
}, now);
|
|
791
|
+
}
|
|
732
792
|
recordTaskEvent(tx, task.id, "task.completed", { by: actor, summary }, now);
|
|
733
793
|
// A terminal Task must never leave a Task-lane signal that can wake it.
|
|
734
794
|
// The durable records remain intact; only the derived mailbox work is
|
|
@@ -747,9 +807,21 @@ function completeTaskCommand(args, store, options) {
|
|
|
747
807
|
runtimeCleanupTargets,
|
|
748
808
|
finalReview: undefined,
|
|
749
809
|
resumedPendingFinalReview: false,
|
|
750
|
-
terminalizedLeaderRun
|
|
810
|
+
terminalizedLeaderRun,
|
|
811
|
+
publishedTreeAuthorization: undefined
|
|
751
812
|
};
|
|
752
813
|
});
|
|
814
|
+
if (result.publishedTreeAuthorization !== undefined) {
|
|
815
|
+
notifyMailbox(options.runtime, leaderMailbox(result.task.id), result.task.id);
|
|
816
|
+
const authorization = result.publishedTreeAuthorization;
|
|
817
|
+
return output(authorization.created
|
|
818
|
+
? `Authorized published-tree completion for ${result.task.id} as ${authorization.event.id}; exact Task Leader completion is required.\n`
|
|
819
|
+
: `Published-tree completion is already authorized for ${result.task.id} as ${authorization.event.id}; exact Task Leader completion is required.\n`, {
|
|
820
|
+
task: result.task,
|
|
821
|
+
authorizationEvent: authorization.event,
|
|
822
|
+
publishedTreeProof: authorization.proof
|
|
823
|
+
});
|
|
824
|
+
}
|
|
753
825
|
if (result.changed) {
|
|
754
826
|
for (const target of result.runtimeCleanupTargets) {
|
|
755
827
|
// Cleanup owns an independent lifecycle lane. Never fall back to the
|
|
@@ -2608,6 +2680,8 @@ function taskReviewCommand(args, store, options) {
|
|
|
2608
2680
|
const [command, ...rest] = args;
|
|
2609
2681
|
if (command === "request")
|
|
2610
2682
|
return requestTaskReviewRound(rest, store, options);
|
|
2683
|
+
if (command === "force-fresh")
|
|
2684
|
+
return forceFreshTaskReviewRound(rest, store, options);
|
|
2611
2685
|
if (command === "retry")
|
|
2612
2686
|
return retryFailedTaskReviewRound(rest, store, options);
|
|
2613
2687
|
if (command === "group")
|
|
@@ -3078,6 +3152,209 @@ function requestTaskReviewRound(args, store, options) {
|
|
|
3078
3152
|
: `Task-final delta-recheck requested as ${round.id} (rechecks ${round.deltaRecheck.previousReviewRoundId})\n`
|
|
3079
3153
|
: `Task-final Review is already ${round.status}: ${round.id}\n`, { reviewRound: round });
|
|
3080
3154
|
}
|
|
3155
|
+
const TASK_FINAL_FORCE_FRESH_EVENT = "review.task-final-force-fresh-requested";
|
|
3156
|
+
/**
|
|
3157
|
+
* Creates a distinct full Task-final ReviewRound only when the exact previous
|
|
3158
|
+
* Round failed without producing semantic review evidence. The source Round,
|
|
3159
|
+
* Run, findings, workspace, and terminal report remain immutable history; the
|
|
3160
|
+
* linking Event is both the audit record and the idempotence key.
|
|
3161
|
+
*/
|
|
3162
|
+
function forceFreshTaskReviewRound(args, store, options) {
|
|
3163
|
+
const usage = "Task review force-fresh usage: yui task review force-fresh <task>/<review-round>.";
|
|
3164
|
+
exactPositionals(args, 1, usage);
|
|
3165
|
+
const reference = taskRecordReference(args[0], "reviewRound", "ReviewRound reference", options);
|
|
3166
|
+
const now = clock(options);
|
|
3167
|
+
const result = store.transaction((tx) => {
|
|
3168
|
+
const source = tx.getReviewRound(reference.taskId, reference.localId);
|
|
3169
|
+
if (source === null) {
|
|
3170
|
+
throw dataError(`ReviewRound not found: ${reference.taskId}/${reference.localId}.`);
|
|
3171
|
+
}
|
|
3172
|
+
const task = requireTask(tx, reference.taskId);
|
|
3173
|
+
if (task.status !== "active")
|
|
3174
|
+
throw usageError(`Task is not active: ${task.id}.`);
|
|
3175
|
+
if (taskActor(options, task.id) !== "leader") {
|
|
3176
|
+
throw usageError("Only the Task Leader may force a fresh Task-final ReviewRound.");
|
|
3177
|
+
}
|
|
3178
|
+
if ((source.scope ?? "work-item") !== "task") {
|
|
3179
|
+
throw usageError(`ReviewRound ${source.id} is not a Task-final ReviewRound.`);
|
|
3180
|
+
}
|
|
3181
|
+
const replacementEvents = tx.listEvents(task.id).filter((event) => (event.type === TASK_FINAL_FORCE_FRESH_EVENT
|
|
3182
|
+
&& event.payload.sourceReviewRoundId === source.id));
|
|
3183
|
+
if (replacementEvents.length > 1) {
|
|
3184
|
+
throw dataError(`ReviewRound ${source.id} has duplicate force-fresh audit events.`);
|
|
3185
|
+
}
|
|
3186
|
+
const replacementEvent = replacementEvents[0];
|
|
3187
|
+
if (replacementEvent !== undefined) {
|
|
3188
|
+
const replacementId = replacementEvent.payload.reviewRoundId;
|
|
3189
|
+
const replacement = replacementId === undefined
|
|
3190
|
+
? null
|
|
3191
|
+
: tx.getReviewRound(task.id, replacementId);
|
|
3192
|
+
if (replacement === null
|
|
3193
|
+
|| replacement.id === source.id
|
|
3194
|
+
|| (replacement.scope ?? "work-item") !== "task"
|
|
3195
|
+
|| replacement.workItemId !== source.workItemId
|
|
3196
|
+
|| replacement.candidateId !== source.candidateId
|
|
3197
|
+
|| replacement.reviewerRoleName !== source.reviewerRoleName
|
|
3198
|
+
|| replacement.deltaRecheck !== undefined
|
|
3199
|
+
|| !sameTaskFinalReviewContract(replacement.taskFinalReviewContract, source.taskFinalReviewContract)
|
|
3200
|
+
|| !isSameTaskReviewCandidate(replacement.taskCandidate, source.taskCandidate)
|
|
3201
|
+
|| replacementEvent.payload.workItemId !== replacement.workItemId
|
|
3202
|
+
|| replacementEvent.payload.candidateId !== replacement.candidateId
|
|
3203
|
+
|| replacementEvent.payload.reviewerRoleName !== replacement.reviewerRoleName
|
|
3204
|
+
|| replacementEvent.payload.taskCandidate !== JSON.stringify(replacement.taskCandidate)) {
|
|
3205
|
+
throw dataError(`Force-fresh audit for ${source.id} does not match its replacement Round.`);
|
|
3206
|
+
}
|
|
3207
|
+
return { round: replacement, source, created: false };
|
|
3208
|
+
}
|
|
3209
|
+
const semanticBlocker = forceFreshSemanticBlocker(tx, source);
|
|
3210
|
+
if (semanticBlocker !== null) {
|
|
3211
|
+
throw usageError(`ReviewRound ${source.id} is not eligible for force-fresh: ${semanticBlocker}`);
|
|
3212
|
+
}
|
|
3213
|
+
if (source.taskCandidate === undefined) {
|
|
3214
|
+
throw dataError(`ReviewRound ${source.id} has no frozen Task candidate.`);
|
|
3215
|
+
}
|
|
3216
|
+
const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
|
|
3217
|
+
if (!sameTaskFinalReviewContract(source.taskFinalReviewContract, taskFinalContract)) {
|
|
3218
|
+
throw usageError(`Task final-review contract does not match ReviewRound ${source.id}.`);
|
|
3219
|
+
}
|
|
3220
|
+
if (source.executionGroup !== undefined
|
|
3221
|
+
&& (source.executionGroup.strategy.mode !== "fixed"
|
|
3222
|
+
|| source.executionGroup.strategy.count !== 1
|
|
3223
|
+
|| source.executionGroup.lanes.length !== 1
|
|
3224
|
+
|| source.executionGroup.lanes[0].roleName !== source.reviewerRoleName)) {
|
|
3225
|
+
throw usageError(`ReviewRound ${source.id} is not a single-Reviewer full Review; force-fresh is refused.`);
|
|
3226
|
+
}
|
|
3227
|
+
const item = tx.getWorkItem(task.id, source.workItemId);
|
|
3228
|
+
const candidate = item?.candidates.find(({ id }) => id === source.candidateId);
|
|
3229
|
+
if (item === null || item === undefined || candidate === undefined) {
|
|
3230
|
+
throw dataError(`Final Review anchor Candidate is no longer available: `
|
|
3231
|
+
+ `${source.workItemId}/${source.candidateId}.`);
|
|
3232
|
+
}
|
|
3233
|
+
const provenance = taskReviewProvenance(tx, task, options);
|
|
3234
|
+
if (!isSameTaskReviewCandidate(source.taskCandidate, provenance.candidate)) {
|
|
3235
|
+
throw usageError(`Final ReviewRound ${source.id} freezes a candidate that is no longer the current Task candidate.`);
|
|
3236
|
+
}
|
|
3237
|
+
const producerCollision = taskReviewProducerCollision(provenance, source.reviewerRoleName);
|
|
3238
|
+
if (producerCollision !== null)
|
|
3239
|
+
throw usageError(producerCollision);
|
|
3240
|
+
const taskRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id)
|
|
3241
|
+
.filter((entry) => (entry.scope ?? "work-item") === "task"));
|
|
3242
|
+
const sourceIndex = taskRounds.findIndex(({ id }) => id === source.id);
|
|
3243
|
+
if (sourceIndex < 0) {
|
|
3244
|
+
throw dataError(`Final ReviewRound is not in Task history: ${source.id}.`);
|
|
3245
|
+
}
|
|
3246
|
+
const laterRound = taskRounds.slice(sourceIndex + 1).at(-1);
|
|
3247
|
+
if (laterRound !== undefined) {
|
|
3248
|
+
throw usageError(`A newer Task-final ReviewRound already exists after ${source.id}: `
|
|
3249
|
+
+ `${laterRound.id}/${laterRound.status}.`);
|
|
3250
|
+
}
|
|
3251
|
+
assertNoConflictingTaskReviewRound(taskRounds, source.id);
|
|
3252
|
+
assertTaskReviewRequestLane(tx, task.id, source.reviewerRoleName);
|
|
3253
|
+
let reviewer = tx.getRole(task.id, source.reviewerRoleName);
|
|
3254
|
+
if (reviewer === null) {
|
|
3255
|
+
if (tx.getGlobalRole(source.reviewerRoleName) === null) {
|
|
3256
|
+
throw usageError(`Global Role not found: ${source.reviewerRoleName}.`);
|
|
3257
|
+
}
|
|
3258
|
+
reviewer = createTaskRole(tx, task, source.reviewerRoleName, undefined, now, source.reviewerRoleName);
|
|
3259
|
+
tx.saveRole(task.id, reviewer);
|
|
3260
|
+
}
|
|
3261
|
+
let created = createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, source.workItemId, source.candidateId, source.reviewerRoleName, "leader", source.taskCandidate, now, taskFinalContract);
|
|
3262
|
+
const group = createExecutionGroup(`execution-group-${created.id}`, task.id, {
|
|
3263
|
+
purpose: "review",
|
|
3264
|
+
target: executionTargetForReviewRound(task, created, item, candidate),
|
|
3265
|
+
strategy: { mode: "fixed", count: 1 },
|
|
3266
|
+
lanes: [{ roleName: reviewer.name, reviewRoundId: created.id }]
|
|
3267
|
+
}, now);
|
|
3268
|
+
created = { ...created, executionGroup: group };
|
|
3269
|
+
tx.saveReviewRound(task.id, created);
|
|
3270
|
+
recordTaskEvent(tx, task.id, TASK_FINAL_FORCE_FRESH_EVENT, {
|
|
3271
|
+
sourceReviewRoundId: source.id,
|
|
3272
|
+
...(source.reviewerRunId === undefined ? {} : { sourceReviewerRunId: source.reviewerRunId }),
|
|
3273
|
+
reviewRoundId: created.id,
|
|
3274
|
+
workItemId: created.workItemId,
|
|
3275
|
+
candidateId: created.candidateId,
|
|
3276
|
+
reviewerRoleName: created.reviewerRoleName,
|
|
3277
|
+
taskCandidate: JSON.stringify(created.taskCandidate),
|
|
3278
|
+
reason: "source-round-failed-without-semantic-review",
|
|
3279
|
+
leaderActionRunId: taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome) ?? "leader"
|
|
3280
|
+
}, now);
|
|
3281
|
+
return { round: created, source, created: true };
|
|
3282
|
+
});
|
|
3283
|
+
return output(result.created
|
|
3284
|
+
? `Fresh Task-final Review requested as ${result.round.id} after ${result.source.id}\n`
|
|
3285
|
+
: `Fresh Task-final Review already requested as ${result.round.id} after ${result.source.id}\n`, { reviewRound: result.round, sourceReviewRound: result.source });
|
|
3286
|
+
}
|
|
3287
|
+
/** Returns the exact semantic evidence that makes a failed Round ineligible. */
|
|
3288
|
+
function forceFreshSemanticBlocker(store, round) {
|
|
3289
|
+
if (round.status !== "failed")
|
|
3290
|
+
return `source status is ${round.status}, not failed.`;
|
|
3291
|
+
if ((round.checks ?? []).length > 0)
|
|
3292
|
+
return "the Round records review checks.";
|
|
3293
|
+
if (round.evidenceCommit !== undefined)
|
|
3294
|
+
return "the Round records a review evidence commit.";
|
|
3295
|
+
if (round.report !== round.summary) {
|
|
3296
|
+
return "the Round stores a report distinct from its failure summary.";
|
|
3297
|
+
}
|
|
3298
|
+
if (runtimeFailureSummaryHasReviewerOutput(round.report ?? "")) {
|
|
3299
|
+
return "the Round stores non-empty Reviewer output in its runtime failure summary.";
|
|
3300
|
+
}
|
|
3301
|
+
if (round.deltaRecheck?.disposition !== undefined
|
|
3302
|
+
|| round.deltaRecheck?.reasoning !== undefined) {
|
|
3303
|
+
return "the Round records a semantic delta-recheck disposition.";
|
|
3304
|
+
}
|
|
3305
|
+
const semanticLane = round.executionGroup?.lanes.find((lane) => (lane.status === "yielded"
|
|
3306
|
+
|| lane.status === "completed"
|
|
3307
|
+
|| lane.result?.report !== undefined
|
|
3308
|
+
|| (lane.result?.checks ?? []).length > 0
|
|
3309
|
+
|| (lane.result?.findings ?? []).length > 0
|
|
3310
|
+
|| (lane.result?.evidence ?? []).length > 0
|
|
3311
|
+
|| lane.result?.evidenceCommit !== undefined));
|
|
3312
|
+
if (semanticLane !== undefined) {
|
|
3313
|
+
return `Reviewer Lane ${semanticLane.id} delivered semantic evidence.`;
|
|
3314
|
+
}
|
|
3315
|
+
const yieldedRun = store.listAgentRuns(round.taskId).find((run) => (run.purpose === "review"
|
|
3316
|
+
&& run.reviewRoundId === round.id
|
|
3317
|
+
&& run.status === "yielded"));
|
|
3318
|
+
if (yieldedRun !== undefined)
|
|
3319
|
+
return `Reviewer Run ${yieldedRun.id} yielded a report.`;
|
|
3320
|
+
const finding = store.listReviewFindings(round.taskId).find((entry) => (entry.firstReviewRoundId === round.id || entry.lastReviewRoundId === round.id));
|
|
3321
|
+
if (finding !== undefined)
|
|
3322
|
+
return `Review finding ${finding.id} references the Round.`;
|
|
3323
|
+
const semanticEvent = store.listEvents(round.taskId).find((event) => (event.type === "review.completed" && event.payload.reviewRoundId === round.id));
|
|
3324
|
+
if (semanticEvent !== undefined)
|
|
3325
|
+
return `Review completion Event ${semanticEvent.id} exists.`;
|
|
3326
|
+
if (looksLikeStructuredReviewReport(round.report ?? "")) {
|
|
3327
|
+
return "the Round stores a structured reviewer report.";
|
|
3328
|
+
}
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
3331
|
+
function runtimeFailureSummaryHasReviewerOutput(summary) {
|
|
3332
|
+
const match = /(?:^|\n)last_assistant_message:[ \t]*([\s\S]*)$/u.exec(summary);
|
|
3333
|
+
const output = match?.[1];
|
|
3334
|
+
return output !== undefined && output.trim().length > 0;
|
|
3335
|
+
}
|
|
3336
|
+
function looksLikeStructuredReviewReport(report) {
|
|
3337
|
+
let parsed;
|
|
3338
|
+
try {
|
|
3339
|
+
parsed = JSON.parse(report);
|
|
3340
|
+
}
|
|
3341
|
+
catch {
|
|
3342
|
+
return false;
|
|
3343
|
+
}
|
|
3344
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
3345
|
+
return false;
|
|
3346
|
+
const record = parsed;
|
|
3347
|
+
return [
|
|
3348
|
+
"summary",
|
|
3349
|
+
"report",
|
|
3350
|
+
"checks",
|
|
3351
|
+
"findings",
|
|
3352
|
+
"evidence",
|
|
3353
|
+
"evidenceCommit",
|
|
3354
|
+
"deltaDisposition",
|
|
3355
|
+
"deltaReasoning"
|
|
3356
|
+
].some((key) => Object.hasOwn(record, key));
|
|
3357
|
+
}
|
|
3081
3358
|
/**
|
|
3082
3359
|
* Issue 07: re-validates the CLI-computed delta preflight inside the store
|
|
3083
3360
|
* transaction. The previous Round must be a completed acceptance (a full
|
|
@@ -3307,13 +3584,16 @@ function taskRunCommand(args, store, options) {
|
|
|
3307
3584
|
function runContextCommand(args, store, options) {
|
|
3308
3585
|
const [first, ...rest] = args;
|
|
3309
3586
|
if (first === "expand") {
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3587
|
+
const usage = "Task run context expand usage: yui task run context expand <task>/<run> <ref-id> [--store <store>] [--mode full].";
|
|
3588
|
+
const parsed = parseTail(rest, new Set(["--store", "--mode"]), usage);
|
|
3589
|
+
exactPositionals(parsed.positionals, 2, usage);
|
|
3590
|
+
const mode = parsed.options.get("--mode");
|
|
3591
|
+
if (mode !== undefined && mode !== "full") {
|
|
3592
|
+
throw usageError("Run Context expansion mode must be full.", usage);
|
|
3313
3593
|
}
|
|
3314
|
-
const { taskId, runId } = parseRunContextReference(
|
|
3594
|
+
const { taskId, runId } = parseRunContextReference(parsed.positionals[0]);
|
|
3315
3595
|
authorizeRunContext(store, taskId, runId, options.environment);
|
|
3316
|
-
const expanded = store.transaction((tx) => expandRunContextRef(tx, taskId, runId,
|
|
3596
|
+
const expanded = store.transaction((tx) => expandRunContextRef(tx, taskId, runId, parsed.positionals[1], optionalNonEmptyOption(parsed.options, "--store")));
|
|
3317
3597
|
return output(`${JSON.stringify(expanded, null, 2)}\n`, { context: expanded });
|
|
3318
3598
|
}
|
|
3319
3599
|
if (first === "delta") {
|
|
@@ -4051,15 +4331,20 @@ function assertPendingFinalReviewWorkspaceEvidence(store, task, round) {
|
|
|
4051
4331
|
}
|
|
4052
4332
|
}
|
|
4053
4333
|
function latestTaskReviewContractAnchor(store, task, taskFinalContract) {
|
|
4054
|
-
const
|
|
4055
|
-
.
|
|
4056
|
-
|
|
4334
|
+
const anchor = store.listWorkItems(task.id)
|
|
4335
|
+
.flatMap((item) => {
|
|
4336
|
+
const candidate = governingWorkItemCandidate(item);
|
|
4337
|
+
return candidate !== undefined && sameTaskFinalReviewContract(candidate.taskFinalReviewContract, taskFinalContract)
|
|
4338
|
+
? [{ item, candidate }]
|
|
4339
|
+
: [];
|
|
4340
|
+
})
|
|
4341
|
+
.sort((left, right) => (left.item.updatedAt.localeCompare(right.item.updatedAt)
|
|
4342
|
+
|| left.item.id.localeCompare(right.item.id)))
|
|
4057
4343
|
.at(-1);
|
|
4058
|
-
|
|
4059
|
-
if (item === undefined || candidate === undefined) {
|
|
4344
|
+
if (anchor === undefined) {
|
|
4060
4345
|
throw usageError(`Task ${task.id} has no WorkItem Candidate to anchor its final Review.`);
|
|
4061
4346
|
}
|
|
4062
|
-
return
|
|
4347
|
+
return anchor;
|
|
4063
4348
|
}
|
|
4064
4349
|
/**
|
|
4065
4350
|
* Leader-only retry of an exact failed Task-final review Run. The old failed
|
|
@@ -5379,6 +5664,44 @@ function recordTaskEventRecord(store, taskId, type, payload, now) {
|
|
|
5379
5664
|
store.saveEvent(taskId, event);
|
|
5380
5665
|
return event;
|
|
5381
5666
|
}
|
|
5667
|
+
function publishedTreeAuthorizationPayload(actor, proof) {
|
|
5668
|
+
return {
|
|
5669
|
+
by: actor,
|
|
5670
|
+
projectId: proof.projectId,
|
|
5671
|
+
publicationId: proof.publicationId,
|
|
5672
|
+
reviewRoundId: proof.reviewRoundId,
|
|
5673
|
+
localCommit: proof.localCommit,
|
|
5674
|
+
remoteCommit: proof.remoteCommit,
|
|
5675
|
+
tree: proof.tree
|
|
5676
|
+
};
|
|
5677
|
+
}
|
|
5678
|
+
function matchingPublishedTreeAuthorization(store, proof) {
|
|
5679
|
+
const events = store.listEvents(proof.taskId);
|
|
5680
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
5681
|
+
const event = events[index];
|
|
5682
|
+
if (event.type === "task.completed" || event.type === "task.reopened")
|
|
5683
|
+
return undefined;
|
|
5684
|
+
if (event.type === TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT
|
|
5685
|
+
&& (event.payload.by === "user" || event.payload.by === "operator")
|
|
5686
|
+
&& event.payload.projectId === proof.projectId
|
|
5687
|
+
&& event.payload.publicationId === proof.publicationId
|
|
5688
|
+
&& event.payload.reviewRoundId === proof.reviewRoundId
|
|
5689
|
+
&& event.payload.localCommit === proof.localCommit
|
|
5690
|
+
&& event.payload.remoteCommit === proof.remoteCommit
|
|
5691
|
+
&& event.payload.tree === proof.tree) {
|
|
5692
|
+
return event;
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
return undefined;
|
|
5696
|
+
}
|
|
5697
|
+
function requirePublishedTreeAuthorization(store, proof) {
|
|
5698
|
+
const authorization = matchingPublishedTreeAuthorization(store, proof);
|
|
5699
|
+
if (authorization === undefined) {
|
|
5700
|
+
throw usageError(`Published-tree completion requires explicit user or global Operator authorization for `
|
|
5701
|
+
+ `${proof.taskId}/${proof.publicationId} at Task-final Review ${proof.reviewRoundId}.`);
|
|
5702
|
+
}
|
|
5703
|
+
return authorization;
|
|
5704
|
+
}
|
|
5382
5705
|
/** Keeps a free-text run-fact note bounded so an event payload stays compact. */
|
|
5383
5706
|
function truncateEventNote(note) {
|
|
5384
5707
|
const normalized = note.trim();
|
|
@@ -6327,6 +6650,9 @@ function workItemRef(taskId, id) {
|
|
|
6327
6650
|
function messageRef(taskId, id) {
|
|
6328
6651
|
return { type: "message", taskId, id };
|
|
6329
6652
|
}
|
|
6653
|
+
function eventRef(taskId, id) {
|
|
6654
|
+
return { type: "event", taskId, id };
|
|
6655
|
+
}
|
|
6330
6656
|
function notifyMailbox(runtime, target, compatibilityTaskId) {
|
|
6331
6657
|
if (runtime?.notifyMailboxChanged !== undefined) {
|
|
6332
6658
|
runtime.notifyMailboxChanged(target);
|
|
@@ -2,7 +2,159 @@ import { usageError } from "../errors/cliError.js";
|
|
|
2
2
|
import { GitIntegrationService } from "../integration/gitIntegrationService.js";
|
|
3
3
|
import { createIntegrationAttempt } from "../integration/integrationAttempt.js";
|
|
4
4
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
5
|
+
import { publicationExternalKey } from "../task/publicationReference.js";
|
|
5
6
|
import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
|
|
7
|
+
/**
|
|
8
|
+
* Verify the one supported ancestry waiver before `task complete` mutates any
|
|
9
|
+
* durable state. The explicit Publication must be the current verified merged
|
|
10
|
+
* record, bind the exact reviewed physical Task head, and name an
|
|
11
|
+
* ancestry-divergent commit with the exact same Git tree.
|
|
12
|
+
*/
|
|
13
|
+
export async function verifyTaskCompletionPublishedTree(taskId, publicationId, store, options = {}) {
|
|
14
|
+
const task = store.getTask(taskId);
|
|
15
|
+
if (task === null)
|
|
16
|
+
throw usageError(`Task not found: ${taskId}.`);
|
|
17
|
+
if (task.status !== "active") {
|
|
18
|
+
throw usageError(`Task is not active: ${task.id}.`);
|
|
19
|
+
}
|
|
20
|
+
const publication = requireCurrentVerifiedPublication(store, task.id, publicationId);
|
|
21
|
+
const binding = task.projectBindings.find(({ projectId }) => (projectId === publication.projectId));
|
|
22
|
+
if (binding === undefined) {
|
|
23
|
+
throw usageError(`Publication ${publication.id} Project is not bound to Task ${task.id}: `
|
|
24
|
+
+ `${publication.projectId}.`);
|
|
25
|
+
}
|
|
26
|
+
if (publication.localCommit === undefined || publication.remoteCommit === undefined) {
|
|
27
|
+
throw usageError(`Publication ${publication.id} must record exact local and remote commits.`);
|
|
28
|
+
}
|
|
29
|
+
const workspace = requireTaskWorkspace(store, task);
|
|
30
|
+
const latestReview = latestTaskFinalReview(store, task.id);
|
|
31
|
+
if (latestReview === undefined
|
|
32
|
+
|| latestReview.status !== "completed"
|
|
33
|
+
|| latestReview.taskCandidate === undefined) {
|
|
34
|
+
throw usageError(`Task ${task.id} requires a latest completed Task-final Review before accepting a published tree.`);
|
|
35
|
+
}
|
|
36
|
+
const git = options.git ?? new NodeGitWorkspace();
|
|
37
|
+
const actualHeads = new Map();
|
|
38
|
+
for (const taskBinding of task.projectBindings) {
|
|
39
|
+
const entry = workspaceProjectEntry(workspace, taskBinding.projectId);
|
|
40
|
+
if (entry === undefined || entry.access !== "write") {
|
|
41
|
+
throw usageError(`Task ${task.id} has no writable managed main workspace for Project ${taskBinding.projectId}.`);
|
|
42
|
+
}
|
|
43
|
+
const reviewedCommit = latestReview.taskCandidate.projects.find(({ projectId }) => (projectId === taskBinding.projectId))?.commit;
|
|
44
|
+
if (reviewedCommit === undefined) {
|
|
45
|
+
throw usageError(`Task-final Review ${latestReview.id} omitted Project ${taskBinding.projectId}.`);
|
|
46
|
+
}
|
|
47
|
+
const actualCommit = (await git.inspect(entry.path, "HEAD")).baseCommit;
|
|
48
|
+
if (actualCommit !== reviewedCommit) {
|
|
49
|
+
throw usageError(`Task-final Review ${latestReview.id} does not match Task head `
|
|
50
|
+
+ `${taskBinding.projectId}@${actualCommit}.`);
|
|
51
|
+
}
|
|
52
|
+
actualHeads.set(taskBinding.projectId, actualCommit);
|
|
53
|
+
}
|
|
54
|
+
if (actualHeads.size !== latestReview.taskCandidate.projects.length) {
|
|
55
|
+
throw usageError(`Task-final Review ${latestReview.id} Project set does not match Task ${task.id}.`);
|
|
56
|
+
}
|
|
57
|
+
const entry = workspaceProjectEntry(workspace, publication.projectId);
|
|
58
|
+
const localCommit = actualHeads.get(publication.projectId);
|
|
59
|
+
if (publication.localCommit !== localCommit) {
|
|
60
|
+
throw usageError(`Publication ${publication.id} local commit ${publication.localCommit} `
|
|
61
|
+
+ `does not match Task head ${localCommit}.`);
|
|
62
|
+
}
|
|
63
|
+
let remoteCommit;
|
|
64
|
+
let localTree;
|
|
65
|
+
let remoteTree;
|
|
66
|
+
try {
|
|
67
|
+
remoteCommit = (await git.inspect(entry.path, publication.remoteCommit)).baseCommit;
|
|
68
|
+
[localTree, remoteTree] = await Promise.all([
|
|
69
|
+
git.resolveTree(entry.path, localCommit),
|
|
70
|
+
git.resolveTree(entry.path, remoteCommit)
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw usageError(`Publication ${publication.id} commit/tree evidence is unavailable: `
|
|
75
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
if (remoteCommit !== publication.remoteCommit) {
|
|
78
|
+
throw usageError(`Publication ${publication.id} remote commit changed: expected `
|
|
79
|
+
+ `${publication.remoteCommit}, found ${remoteCommit}.`);
|
|
80
|
+
}
|
|
81
|
+
const [localContainsRemote, remoteContainsLocal] = await Promise.all([
|
|
82
|
+
git.isAncestor(entry.path, remoteCommit, localCommit),
|
|
83
|
+
git.isAncestor(entry.path, localCommit, remoteCommit)
|
|
84
|
+
]);
|
|
85
|
+
if (localContainsRemote || remoteContainsLocal) {
|
|
86
|
+
throw usageError(`Publication ${publication.id} is not ancestry-divergent from Task head ${localCommit}; `
|
|
87
|
+
+ "use normal Task completion.");
|
|
88
|
+
}
|
|
89
|
+
if (localTree !== remoteTree) {
|
|
90
|
+
throw usageError(`Publication ${publication.id} Git trees differ: `
|
|
91
|
+
+ `${localCommit}^{tree}=${localTree}, ${remoteCommit}^{tree}=${remoteTree}.`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
taskId: task.id,
|
|
95
|
+
projectId: publication.projectId,
|
|
96
|
+
publicationId: publication.id,
|
|
97
|
+
reviewRoundId: latestReview.id,
|
|
98
|
+
localCommit,
|
|
99
|
+
remoteCommit,
|
|
100
|
+
tree: localTree
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Re-derive every durable half of the asynchronous Git proof under the Task
|
|
104
|
+
* completion transaction. The physical heads are the CLI snapshot captured
|
|
105
|
+
* immediately before mutation; any record or head drift fails closed. */
|
|
106
|
+
export function assertTaskCompletionPublishedTreeProof(store, task, publicationId, proof, actualCandidate) {
|
|
107
|
+
if (proof === undefined
|
|
108
|
+
|| proof.taskId !== task.id
|
|
109
|
+
|| proof.publicationId !== publicationId) {
|
|
110
|
+
throw usageError(`Published-tree completion proof is missing or mismatched for ${task.id}/${publicationId}.`);
|
|
111
|
+
}
|
|
112
|
+
const publication = requireCurrentVerifiedPublication(store, task.id, publicationId);
|
|
113
|
+
if (publication.projectId !== proof.projectId
|
|
114
|
+
|| publication.localCommit !== proof.localCommit
|
|
115
|
+
|| publication.remoteCommit !== proof.remoteCommit) {
|
|
116
|
+
throw usageError(`Publication evidence changed before Task completion: ${publication.id}.`);
|
|
117
|
+
}
|
|
118
|
+
const latestReview = latestTaskFinalReview(store, task.id);
|
|
119
|
+
if (latestReview === undefined
|
|
120
|
+
|| latestReview.id !== proof.reviewRoundId
|
|
121
|
+
|| latestReview.status !== "completed"
|
|
122
|
+
|| latestReview.taskCandidate === undefined
|
|
123
|
+
|| !sameTaskCandidate(latestReview.taskCandidate, actualCandidate)) {
|
|
124
|
+
throw usageError(`Task-final Review evidence changed before published-tree completion: ${task.id}.`);
|
|
125
|
+
}
|
|
126
|
+
const actualCommit = actualCandidate.projects.find(({ projectId }) => (projectId === proof.projectId))?.commit;
|
|
127
|
+
if (actualCommit !== proof.localCommit) {
|
|
128
|
+
throw usageError(`Task head changed before published-tree completion: `
|
|
129
|
+
+ `${proof.projectId}@${actualCommit ?? "missing"}.`);
|
|
130
|
+
}
|
|
131
|
+
return proof;
|
|
132
|
+
}
|
|
133
|
+
function requireCurrentVerifiedPublication(store, taskId, publicationId) {
|
|
134
|
+
const publication = store.getPublicationReference(taskId, publicationId);
|
|
135
|
+
if (publication === null) {
|
|
136
|
+
throw usageError(`Publication reference not found: ${taskId}/${publicationId}.`);
|
|
137
|
+
}
|
|
138
|
+
const current = store.findPublicationReferenceByExternalKey(publicationExternalKey(publication));
|
|
139
|
+
if (current === null || current.taskId !== taskId || current.id !== publication.id) {
|
|
140
|
+
throw usageError(`Publication ${publication.id} is not the current unsuperseded record for its external identity.`);
|
|
141
|
+
}
|
|
142
|
+
if (publication.state !== "merged" || publication.verification !== "verified") {
|
|
143
|
+
throw usageError(`Publication ${publication.id} must be merged and verified before Task completion.`);
|
|
144
|
+
}
|
|
145
|
+
return publication;
|
|
146
|
+
}
|
|
147
|
+
function latestTaskFinalReview(store, taskId) {
|
|
148
|
+
return store.listReviewRounds(taskId)
|
|
149
|
+
.filter((round) => (round.scope ?? "work-item") === "task")
|
|
150
|
+
.sort((left, right) => (left.id.localeCompare(right.id, undefined, { numeric: true })))
|
|
151
|
+
.at(-1);
|
|
152
|
+
}
|
|
153
|
+
function sameTaskCandidate(left, right) {
|
|
154
|
+
return left.projects.length === right.projects.length
|
|
155
|
+
&& left.projects.every((project, index) => (project.projectId === right.projects[index]?.projectId
|
|
156
|
+
&& project.commit === right.projects[index]?.commit));
|
|
157
|
+
}
|
|
6
158
|
/**
|
|
7
159
|
* Reconcile configured remote baselines before a Task completion attempt.
|
|
8
160
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
|
|
1
2
|
import { RUN_BOOTSTRAP_MAX_DELTAS } from "./runContextContract.js";
|
|
2
3
|
import { contextContentDigest, contextSnapshotRef, createContextSnapshot, validateContextSnapshot } from "./contextSnapshot.js";
|
|
3
4
|
export const RUN_CONTEXT_PACK_SCHEMA_VERSION = 1;
|
|
@@ -123,11 +124,12 @@ export function buildRunContextPack(store, taskId, runId) {
|
|
|
123
124
|
});
|
|
124
125
|
return pack;
|
|
125
126
|
}
|
|
126
|
-
export function expandRunContextRef(store, taskId, runId, refId) {
|
|
127
|
+
export function expandRunContextRef(store, taskId, runId, refId, refStore) {
|
|
127
128
|
const pack = buildRunContextPack(store, taskId, runId);
|
|
128
|
-
const authorized = pack.pointers.filter((ref) => ref.refId === refId);
|
|
129
|
+
const authorized = pack.pointers.filter((ref) => (ref.refId === refId && (refStore === undefined || ref.store === refStore)));
|
|
130
|
+
const selector = refStore === undefined ? refId : `${refStore}/${refId}`;
|
|
129
131
|
if (authorized.length !== 1) {
|
|
130
|
-
throw new Error(`Run Context ref is not uniquely authorized: ${
|
|
132
|
+
throw new Error(`Run Context ref is not uniquely authorized: ${selector}.`);
|
|
131
133
|
}
|
|
132
134
|
const run = requireExactRun(store, taskId, runId);
|
|
133
135
|
const snapshotRef = run.assignment.contextSnapshotRef;
|
|
@@ -135,7 +137,7 @@ export function expandRunContextRef(store, taskId, runId, refId) {
|
|
|
135
137
|
? collectAuthorizedContext(store, run).find(({ ref }) => (contextRefIdentity(ref) === contextRefIdentity(authorized[0])))
|
|
136
138
|
: store.getContextSnapshot(taskId, snapshotRef.id)?.resources.find(({ ref }) => (contextRefIdentity(ref) === contextRefIdentity(authorized[0])));
|
|
137
139
|
if (materialized === undefined || materialized.ref.digest !== authorized[0].digest) {
|
|
138
|
-
throw new Error(`Run Context ref is unavailable or drifted: ${
|
|
140
|
+
throw new Error(`Run Context ref is unavailable or drifted: ${selector}.`);
|
|
139
141
|
}
|
|
140
142
|
const bytes = Buffer.byteLength(JSON.stringify(materialized.value), "utf8");
|
|
141
143
|
if (bytes > RUN_CONTEXT_EXPAND_MAX_BYTES) {
|
|
@@ -261,6 +263,19 @@ function collectAuthorizedContext(store, run) {
|
|
|
261
263
|
for (const message of store.listMessages(task.id).slice(-16)) {
|
|
262
264
|
result.push(materialize("L4", "task-message", message.id, message));
|
|
263
265
|
}
|
|
266
|
+
const publishedTreeAuthorizations = [];
|
|
267
|
+
const events = store.listEvents(task.id);
|
|
268
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
269
|
+
const event = events[index];
|
|
270
|
+
if (event.type === "task.completed" || event.type === "task.reopened")
|
|
271
|
+
break;
|
|
272
|
+
if (event.type === TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT) {
|
|
273
|
+
publishedTreeAuthorizations.push(event);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const event of publishedTreeAuthorizations.reverse().slice(-16)) {
|
|
277
|
+
result.push(materialize("L4", "task-event", event.id, event));
|
|
278
|
+
}
|
|
264
279
|
for (const request of store.listOpenInputRequests([task.id])) {
|
|
265
280
|
result.push(materialize("L4", "input-request", request.id, request));
|
|
266
281
|
}
|
|
@@ -57,7 +57,7 @@ export function materializeSessionBootstrap(input) {
|
|
|
57
57
|
}
|
|
58
58
|
: {
|
|
59
59
|
loadCommand: `\"${sessionCliPath}\" task run context \"$YUI_TASK_ID/<run-id>\" --json`,
|
|
60
|
-
expandCommand: `\"${sessionCliPath}\" task run context expand \"$YUI_TASK_ID/<run-id>\" <ref-id> --mode full --json`
|
|
60
|
+
expandCommand: `\"${sessionCliPath}\" task run context expand \"$YUI_TASK_ID/<run-id>\" <ref-id> --store <store> --mode full --json`
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const manifest = Object.freeze({ ...body, digest: digest(body) });
|
|
@@ -15,15 +15,18 @@ export class RemoteBaselineConflictError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
/** The small Git boundary used by project registration and Task workspaces. */
|
|
17
17
|
export class NodeGitWorkspace {
|
|
18
|
+
async resolveTree(repositoryPath, commit) {
|
|
19
|
+
return gitLine([
|
|
20
|
+
"-C", repositoryPath,
|
|
21
|
+
"rev-parse", "--verify", "--end-of-options", `${commit}^{tree}`
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
18
24
|
async findCommitWithSameTreeInHistory(input) {
|
|
19
25
|
const source = (await this.inspect(input.repositoryPath, input.sourceCommit)).baseCommit;
|
|
20
26
|
const history = (await this.inspect(input.repositoryPath, input.historyHead)).baseCommit;
|
|
21
27
|
if (await this.isAncestor(input.repositoryPath, source, history))
|
|
22
28
|
return source;
|
|
23
|
-
const sourceTree = await
|
|
24
|
-
"-C", input.repositoryPath,
|
|
25
|
-
"rev-parse", "--verify", "--end-of-options", `${source}^{tree}`
|
|
26
|
-
]);
|
|
29
|
+
const sourceTree = await this.resolveTree(input.repositoryPath, source);
|
|
27
30
|
const pageSize = 1000;
|
|
28
31
|
for (let skip = 0;; skip += pageSize) {
|
|
29
32
|
const output = await git([
|
|
@@ -107,10 +107,12 @@ export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
|
|
|
107
107
|
}));
|
|
108
108
|
return { taskId, refreshed: options.refresh === true, entries };
|
|
109
109
|
}
|
|
110
|
-
export function assertTaskBaseFreshnessForCompletion(report) {
|
|
110
|
+
export function assertTaskBaseFreshnessForCompletion(report, options = {}) {
|
|
111
111
|
const warnings = [];
|
|
112
112
|
for (const entry of report.entries) {
|
|
113
|
-
|
|
113
|
+
const acceptedPublishedTree = options.acceptedPublishedTreeProjectId === entry.projectId;
|
|
114
|
+
if ((entry.status === "behind" || entry.status === "diverged")
|
|
115
|
+
&& !acceptedPublishedTree) {
|
|
114
116
|
throw usageError(`Task ${report.taskId} Project ${entry.projectId} base is ${entry.status}; `
|
|
115
117
|
+ `run 'yui task base status ${report.taskId} --refresh' and choose an explicit delivery base. `
|
|
116
118
|
+ "Safe resolutions are to rebase or merge the Task workspace onto the refreshed remote base, "
|
package/dist/run/agentRun.js
CHANGED
|
@@ -67,11 +67,11 @@ export function withAgentRunContextSnapshot(run, snapshot, deltaRefIds = []) {
|
|
|
67
67
|
if (run.status !== "active" || run.pushedAt !== undefined || run.deliveredAt !== undefined) {
|
|
68
68
|
throw new Error(`Cannot bind Context Snapshot after Run delivery: ${run.id}.`);
|
|
69
69
|
}
|
|
70
|
-
const assignment =
|
|
70
|
+
const assignment = createRunAssignment({
|
|
71
71
|
...run.assignment,
|
|
72
72
|
contextSnapshotRef: snapshot,
|
|
73
73
|
deltaRefIds
|
|
74
|
-
})
|
|
74
|
+
});
|
|
75
75
|
return validateAgentRun(Object.freeze({
|
|
76
76
|
...run,
|
|
77
77
|
assignment,
|
|
@@ -732,6 +732,8 @@ export class SqliteTaskStore {
|
|
|
732
732
|
return null;
|
|
733
733
|
return {
|
|
734
734
|
...base,
|
|
735
|
+
agentRuns: this.listAgentRuns(taskId),
|
|
736
|
+
roleSessionSets: this.listRoleSessionSets(taskId),
|
|
735
737
|
managedWorkspaces: this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner)),
|
|
736
738
|
durableJobs: this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id),
|
|
737
739
|
integrationQueueEntries: this.#sortById(this.#listPayload("integration_queue", "task_id = ?", [taskId]), (entry) => entry.id),
|
|
@@ -442,6 +442,8 @@ export class FileTaskStore {
|
|
|
442
442
|
}
|
|
443
443
|
return {
|
|
444
444
|
...base,
|
|
445
|
+
agentRuns: this.listAgentRuns(taskId),
|
|
446
|
+
roleSessionSets: this.listRoleSessionSets(taskId),
|
|
445
447
|
managedWorkspaces: values(aggregate.managedWorkspaces, (workspace) => managedWorkspaceKey(workspace.owner)),
|
|
446
448
|
durableJobs: values(aggregate.durableJobs, "id"),
|
|
447
449
|
integrationQueueEntries: values(aggregate.integrationQueue, "id"),
|
|
@@ -142,6 +142,8 @@ export function projectCompletionReadiness(facts, options = {}) {
|
|
|
142
142
|
}
|
|
143
143
|
// Provider continuations that may still write the Workspace.
|
|
144
144
|
for (const continuation of blockingProviderContinuations(facts.events)) {
|
|
145
|
+
if (!providerContinuationBlocksCompletion(continuation, facts))
|
|
146
|
+
continue;
|
|
145
147
|
const identity = continuation.identity;
|
|
146
148
|
blockers.push({
|
|
147
149
|
code: "blocking-provider-continuation",
|
|
@@ -209,6 +211,47 @@ export function projectCompletionReadiness(facts, options = {}) {
|
|
|
209
211
|
});
|
|
210
212
|
return { taskId: task.id, ready: sorted.length === 0, blockers: sorted };
|
|
211
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* A terminal Run releases only its completion blocker, never its immutable
|
|
216
|
+
* continuation audit. Missing or inconsistent ownership remains ambiguous and
|
|
217
|
+
* therefore fail-closed. A live exact native Session can still deliver or
|
|
218
|
+
* write for a terminal Run, so it retains the blocker until that Session is
|
|
219
|
+
* stopped, broken, or replaced by another Conversation identity.
|
|
220
|
+
*/
|
|
221
|
+
function providerContinuationBlocksCompletion(continuation, facts) {
|
|
222
|
+
if (continuation.identityConflict)
|
|
223
|
+
return true;
|
|
224
|
+
const ownerRun = facts.agentRuns.find(({ id }) => id === continuation.runId);
|
|
225
|
+
if (ownerRun === undefined
|
|
226
|
+
|| ownerRun.taskId !== continuation.taskId
|
|
227
|
+
|| ownerRun.roleName !== continuation.roleName
|
|
228
|
+
|| ownerRun.effective.agentId !== continuation.identity.accountScope) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (ownerRun.status !== "failed" && ownerRun.status !== "yielded")
|
|
232
|
+
return true;
|
|
233
|
+
const sessions = facts.roleSessionSets.find(({ owner }) => (owner.taskId === continuation.taskId && owner.roleName === continuation.roleName));
|
|
234
|
+
const session = sessions?.sessions[continuation.identity.accountScope];
|
|
235
|
+
if (session !== undefined
|
|
236
|
+
&& session.status !== "stopped"
|
|
237
|
+
&& session.status !== "broken"
|
|
238
|
+
&& session.nativeSessionId === continuation.identity.conversationId) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
const binding = sessions?.providerBinding;
|
|
242
|
+
if (binding === undefined || binding === null
|
|
243
|
+
|| binding.providerNamespace !== continuation.identity.providerNamespace
|
|
244
|
+
|| binding.accountScope !== continuation.identity.accountScope) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const conversationIsCurrent = binding.conversations.some((conversation) => (conversation.conversationId === continuation.identity.conversationId
|
|
248
|
+
&& conversation.epoch === binding.currentConversationEpoch
|
|
249
|
+
&& conversation.status === "current"));
|
|
250
|
+
const activationIsLive = binding.activations.some((activation) => (activation.activationId === continuation.identity.activationId
|
|
251
|
+
&& activation.conversationId === continuation.identity.conversationId
|
|
252
|
+
&& activation.status === "active"));
|
|
253
|
+
return conversationIsCurrent && activationIsLive;
|
|
254
|
+
}
|
|
212
255
|
function workspaceBlocker(facts, taskId, workspace) {
|
|
213
256
|
const owner = workspace.owner;
|
|
214
257
|
switch (owner.type) {
|
package/dist/task/nextAction.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { sameTaskFinalReviewContract, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
3
|
-
import { currentWorkItemCandidate } from "../workItem/workItem.js";
|
|
3
|
+
import { currentWorkItemCandidate, governingWorkItemCandidate } from "../workItem/workItem.js";
|
|
4
4
|
const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
|
|
5
5
|
export function projectNextAction(facts) {
|
|
6
6
|
const { task } = facts;
|
|
@@ -602,8 +602,7 @@ function selectOpenWorkItem(workItems) {
|
|
|
602
602
|
}
|
|
603
603
|
function taskFinalReviewContract(facts) {
|
|
604
604
|
const contract = facts.workItems
|
|
605
|
-
.
|
|
606
|
-
.map((candidate) => candidate.taskFinalReviewContract)
|
|
605
|
+
.map((item) => governingWorkItemCandidate(item)?.taskFinalReviewContract)
|
|
607
606
|
.find((contract) => contract !== undefined);
|
|
608
607
|
return contract === undefined ? undefined : validateTaskFinalReviewContract(contract);
|
|
609
608
|
}
|
|
@@ -680,7 +679,10 @@ function detectProtocolInconsistency(facts) {
|
|
|
680
679
|
const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
|
|
681
680
|
const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
|
|
682
681
|
const contractedCandidates = facts.workItems
|
|
683
|
-
.flatMap((item) =>
|
|
682
|
+
.flatMap((item) => {
|
|
683
|
+
const candidate = governingWorkItemCandidate(item);
|
|
684
|
+
return candidate === undefined ? [] : [{ item, candidate }];
|
|
685
|
+
})
|
|
684
686
|
.filter(({ candidate }) => candidate.taskFinalReviewContract !== undefined);
|
|
685
687
|
if (contractedCandidates.length > 1) {
|
|
686
688
|
const first = validateTaskFinalReviewContract(contractedCandidates[0].candidate.taskFinalReviewContract);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
|
|
2
2
|
import { validateTaskRecordReference } from "./taskRecordReference.js";
|
|
3
3
|
export const PUBLICATION_REFERENCE_SCHEMA_VERSION = 1;
|
|
4
|
+
export const TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT = "task.completion-published-tree-authorized";
|
|
4
5
|
const PROVIDERS = new Set(["github", "gitlab"]);
|
|
5
6
|
const EXTERNAL_KINDS = new Set(["pull-request", "merge-request"]);
|
|
6
7
|
const STATES = new Set(["open", "merged", "closed"]);
|
|
@@ -567,6 +567,18 @@ export function currentWorkItemCandidate(workItem) {
|
|
|
567
567
|
? workItem.candidates.at(-1)
|
|
568
568
|
: undefined;
|
|
569
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Resolve the one Candidate that still governs Task delivery semantics.
|
|
572
|
+
* Awaiting Candidates remain under Leader disposition, while completed
|
|
573
|
+
* Candidates freeze accepted delivery evidence. Failed, retired, pending,
|
|
574
|
+
* and running WorkItems retain Candidate history only for audit; older
|
|
575
|
+
* Candidates on the same WorkItem are superseded by its latest Candidate.
|
|
576
|
+
*/
|
|
577
|
+
export function governingWorkItemCandidate(workItem) {
|
|
578
|
+
return workItem.status === "awaiting_acceptance" || workItem.status === "completed"
|
|
579
|
+
? workItem.candidates.at(-1)
|
|
580
|
+
: undefined;
|
|
581
|
+
}
|
|
570
582
|
export function updateWorkItemWriteProjects(workItem, writeProjectIds, now) {
|
|
571
583
|
validateWorkItem(workItem);
|
|
572
584
|
if (isTerminalStatus(workItem.status)) {
|
|
@@ -4,6 +4,7 @@ import { createChangeSetManifest } from "../integration/changeSetManifest.js";
|
|
|
4
4
|
import { deriveManifestTags } from "../integration/manifestTags.js";
|
|
5
5
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
6
6
|
import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
7
|
+
import { governingWorkItemCandidate } from "../workItem/workItem.js";
|
|
7
8
|
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
8
9
|
import { captureManagedGitChanges } from "./gitChangeSetCapture.js";
|
|
9
10
|
const CAPTURABLE_WORK_ITEM_STATUSES = new Set([
|
|
@@ -427,7 +428,7 @@ function latestExactDirectAnchorId(store, taskId, contract) {
|
|
|
427
428
|
return undefined;
|
|
428
429
|
return store.listWorkItems(taskId)
|
|
429
430
|
.filter((item) => {
|
|
430
|
-
const candidate = item
|
|
431
|
+
const candidate = governingWorkItemCandidate(item);
|
|
431
432
|
return candidate?.source.type === "direct"
|
|
432
433
|
&& sameTaskFinalReviewContract(candidate.taskFinalReviewContract, contract);
|
|
433
434
|
})
|
package/package.json
CHANGED
|
@@ -521,9 +521,14 @@ authorized expansions.
|
|
|
521
521
|
completion; routine retries and routing do not need an InputRequest.
|
|
522
522
|
- A failed review is terminal evidence, not an automatic retry. Retry a
|
|
523
523
|
WorkItem review with a new `task work review`, accept with an explicit
|
|
524
|
-
rationale, or ask the user.
|
|
525
|
-
|
|
526
|
-
|
|
524
|
+
rationale, or ask the user. `yui task run retry <run-id>` retries an exact
|
|
525
|
+
failed Task-final Reviewer execution under the same semantic ReviewRound. If
|
|
526
|
+
that immutable Round itself failed without any semantic report, checks,
|
|
527
|
+
yield, evidence, or finding, the Leader may run
|
|
528
|
+
`yui task review force-fresh <task>/<review-round>` to create one distinct
|
|
529
|
+
full Round over the identical frozen heads. It fails closed for every
|
|
530
|
+
semantic or ambiguous prior result; target the new failed Round explicitly
|
|
531
|
+
if another non-semantic failure occurs.
|
|
527
532
|
- If the same non-resource user choice or unavailable external fact repeats,
|
|
528
533
|
persist context and create an InputRequest instead of looping. Never use an
|
|
529
534
|
InputRequest to solicit authorization for an unrequested real-resource test;
|
|
@@ -23,12 +23,17 @@ For every managed Task Run:
|
|
|
23
23
|
context-load failure if the pack is missing, stale, unauthorized, malformed,
|
|
24
24
|
or mismatched. Never request an inline/full-prompt fallback.
|
|
25
25
|
4. Use pack summaries and pointers first. Expand only an authorized ref when
|
|
26
|
-
its full value is needed
|
|
26
|
+
its full value is needed, selecting it by the pointer's exact `store` and
|
|
27
|
+
`refId`:
|
|
27
28
|
|
|
28
29
|
```sh
|
|
29
|
-
"$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --mode full --json
|
|
30
|
+
"$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --store <store> --mode full --json
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
A bare `<ref-id>` remains supported only when it identifies exactly one
|
|
34
|
+
authorized pointer. If multiple stores use that id, bare expansion fails
|
|
35
|
+
closed; never guess which store was intended.
|
|
36
|
+
|
|
32
37
|
5. On a later wake, request only the declared delta after the last pack cursor.
|
|
33
38
|
If no cursor is available, reload the exact pack; do not reconstruct state
|
|
34
39
|
from transcript memory.
|