@zq-silk/yui 0.5.3 → 0.6.1
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/agentConfigurationPicker.js +1 -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 +35 -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 +610 -201
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +24 -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 +85 -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 +60 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +413 -61
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- 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/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +238 -22
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +549 -42
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/boundedRpc.js +475 -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/setup/setupCommand.js +27 -4
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +769 -1
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +705 -0
- package/dist/storage/sqliteStore.js +1695 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +298 -0
- package/dist/storage/taskStore.js +982 -21
- package/dist/storage/upgrade/homeClassification.js +157 -12
- 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 +351 -0
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +343 -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 +2 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
|
|
2
|
+
export function detectDeliveryDuplicates(facts, intent) {
|
|
3
|
+
switch (intent.kind) {
|
|
4
|
+
case "create-work-item":
|
|
5
|
+
return detectWorkItemDuplicates(facts, intent.scope);
|
|
6
|
+
case "integration-start":
|
|
7
|
+
return detectIntegrationDuplicates(facts, intent);
|
|
8
|
+
case "review-request":
|
|
9
|
+
return detectReviewDuplicates(facts, intent);
|
|
10
|
+
case "complete-task":
|
|
11
|
+
return detectCompleteDuplicates(facts);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Apply the configured mode. `display` never interferes; `warn` reports every
|
|
16
|
+
* match as a warning; `enforce` hard-blocks on exact evidence and warns on
|
|
17
|
+
* suspected duplicates.
|
|
18
|
+
*/
|
|
19
|
+
export function evaluateDeliveryGuard(duplicates, mode) {
|
|
20
|
+
if (mode === "display" || duplicates.length === 0) {
|
|
21
|
+
return { blocked: null, warnings: [] };
|
|
22
|
+
}
|
|
23
|
+
const exact = duplicates.filter((duplicate) => duplicate.severity === "exact");
|
|
24
|
+
const suspected = duplicates.filter((duplicate) => duplicate.severity === "suspected");
|
|
25
|
+
if (mode === "enforce" && exact.length > 0) {
|
|
26
|
+
return { blocked: exact[0], warnings: suspected };
|
|
27
|
+
}
|
|
28
|
+
return { blocked: null, warnings: [...exact, ...suspected] };
|
|
29
|
+
}
|
|
30
|
+
export function formatDeliveryDuplicate(duplicate) {
|
|
31
|
+
const refs = duplicate.refs.map((ref) => `${ref.kind} ${ref.id}`).join(", ");
|
|
32
|
+
const reuse = duplicate.reuseCommand === undefined
|
|
33
|
+
? ""
|
|
34
|
+
: ` Existing proof: ${duplicate.reuseCommand}`;
|
|
35
|
+
return `${duplicate.severity === "exact" ? "Exact duplicate" : "Suspected duplicate"}: ${duplicate.reason} (${refs}).${reuse}`;
|
|
36
|
+
}
|
|
37
|
+
function detectWorkItemDuplicates(facts, scope) {
|
|
38
|
+
const wanted = normalizeScope(scope);
|
|
39
|
+
const duplicates = [];
|
|
40
|
+
for (const item of facts.workItems) {
|
|
41
|
+
const existing = normalizeScope({
|
|
42
|
+
title: item.title,
|
|
43
|
+
objective: item.objective,
|
|
44
|
+
acceptance: item.acceptance,
|
|
45
|
+
writeProjectIds: item.writeProjectIds
|
|
46
|
+
});
|
|
47
|
+
const sameScope = existing.title === wanted.title
|
|
48
|
+
&& existing.objective === wanted.objective
|
|
49
|
+
&& existing.acceptance === wanted.acceptance
|
|
50
|
+
&& existing.writeProjectIds === wanted.writeProjectIds;
|
|
51
|
+
const ref = { kind: "work-item", id: item.id };
|
|
52
|
+
if (sameScope && OPEN_WORK_ITEM_STATUSES.has(item.status)) {
|
|
53
|
+
duplicates.push({
|
|
54
|
+
severity: "exact",
|
|
55
|
+
reason: `Work Item ${item.id} is already open with the identical scope`,
|
|
56
|
+
refs: [ref],
|
|
57
|
+
reuseCommand: `yui task work show ${facts.task.id}/${item.id}`
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (sameScope && item.status === "completed") {
|
|
62
|
+
duplicates.push({
|
|
63
|
+
severity: "suspected",
|
|
64
|
+
reason: `Work Item ${item.id} already delivered the identical scope; re-creating it may be a duplicate successor`,
|
|
65
|
+
refs: [ref],
|
|
66
|
+
reuseCommand: `yui task work show ${facts.task.id}/${item.id}`
|
|
67
|
+
});
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!sameScope
|
|
71
|
+
&& OPEN_WORK_ITEM_STATUSES.has(item.status)
|
|
72
|
+
&& existing.writeProjectIds === wanted.writeProjectIds
|
|
73
|
+
&& wanted.writeProjectIds.length > 0
|
|
74
|
+
&& overlap(existing.acceptance, wanted.acceptance)) {
|
|
75
|
+
duplicates.push({
|
|
76
|
+
severity: "suspected",
|
|
77
|
+
reason: `Open Work Item ${item.id} shares the same Project scope and acceptance lines`,
|
|
78
|
+
refs: [ref]
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return duplicates;
|
|
83
|
+
}
|
|
84
|
+
function detectIntegrationDuplicates(facts, intent) {
|
|
85
|
+
const wanted = new Set(intent.changeSetIds);
|
|
86
|
+
const duplicates = [];
|
|
87
|
+
for (const attempt of facts.integrations) {
|
|
88
|
+
if (attempt.projectId !== intent.projectId)
|
|
89
|
+
continue;
|
|
90
|
+
const existing = new Set(attempt.changeSetIds);
|
|
91
|
+
const sameSet = existing.size === wanted.size
|
|
92
|
+
&& [...wanted].every((id) => existing.has(id));
|
|
93
|
+
const ref = { kind: "integration-attempt", id: attempt.id };
|
|
94
|
+
if (sameSet && attempt.status === "committed") {
|
|
95
|
+
duplicates.push({
|
|
96
|
+
severity: "exact",
|
|
97
|
+
reason: `Integration ${attempt.id} already committed this exact ChangeSet set`,
|
|
98
|
+
refs: [ref],
|
|
99
|
+
reuseCommand: `yui task integration show ${facts.task.id}/${attempt.id}`
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (sameSet
|
|
104
|
+
&& (attempt.status === "running" || attempt.status === "validating")) {
|
|
105
|
+
duplicates.push({
|
|
106
|
+
severity: "suspected",
|
|
107
|
+
reason: `Integration ${attempt.id} is already ${attempt.status} for this exact ChangeSet set`,
|
|
108
|
+
refs: [ref],
|
|
109
|
+
reuseCommand: `yui task integration show ${facts.task.id}/${attempt.id}`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return duplicates;
|
|
114
|
+
}
|
|
115
|
+
function detectReviewDuplicates(facts, intent) {
|
|
116
|
+
const wanted = new Set(intent.taskCandidateCommits.map((commit) => commit.toLowerCase()));
|
|
117
|
+
const duplicates = [];
|
|
118
|
+
for (const round of facts.reviewRounds) {
|
|
119
|
+
if ((round.scope ?? "work-item") !== "task")
|
|
120
|
+
continue;
|
|
121
|
+
if (round.reviewerRoleName !== intent.reviewerRoleName)
|
|
122
|
+
continue;
|
|
123
|
+
const commits = new Set((round.taskCandidate?.projects ?? []).map((project) => project.commit.toLowerCase()));
|
|
124
|
+
const sameCandidate = commits.size === wanted.size
|
|
125
|
+
&& commits.size > 0
|
|
126
|
+
&& [...wanted].every((commit) => commits.has(commit));
|
|
127
|
+
if (!sameCandidate)
|
|
128
|
+
continue;
|
|
129
|
+
const ref = { kind: "review-round", id: round.id };
|
|
130
|
+
if (round.status === "completed") {
|
|
131
|
+
duplicates.push({
|
|
132
|
+
severity: "exact",
|
|
133
|
+
reason: `Task-final Review ${round.id} already attests this exact head`,
|
|
134
|
+
refs: [ref]
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else if (round.status === "pending" || round.status === "running") {
|
|
138
|
+
duplicates.push({
|
|
139
|
+
severity: "suspected",
|
|
140
|
+
reason: `Task-final Review ${round.id} is already ${round.status} for this exact head`,
|
|
141
|
+
refs: [ref]
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return duplicates;
|
|
146
|
+
}
|
|
147
|
+
function detectCompleteDuplicates(facts) {
|
|
148
|
+
if (facts.task.status === "completed" || facts.task.status === "archived") {
|
|
149
|
+
return [{
|
|
150
|
+
severity: "exact",
|
|
151
|
+
reason: `Task ${facts.task.id} is already ${facts.task.status}`,
|
|
152
|
+
refs: [{ kind: "task", id: facts.task.id }]
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
function normalizeScope(scope) {
|
|
158
|
+
return {
|
|
159
|
+
title: scope.title.trim().toLowerCase(),
|
|
160
|
+
objective: scope.objective.trim().toLowerCase(),
|
|
161
|
+
acceptance: [...scope.acceptance]
|
|
162
|
+
.map((line) => line.trim().toLowerCase())
|
|
163
|
+
.filter((line) => line.length > 0)
|
|
164
|
+
.sort()
|
|
165
|
+
.join("\n"),
|
|
166
|
+
writeProjectIds: [...scope.writeProjectIds].map((id) => id.trim()).sort().join(",")
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function overlap(left, right) {
|
|
170
|
+
const rightLines = new Set(right.split("\n"));
|
|
171
|
+
return left.split("\n").some((line) => line.length > 0 && rightLines.has(line));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Default number of consecutive yielded Leader turns that must produce no
|
|
175
|
+
* durable delivery change before the budget is exhausted.
|
|
176
|
+
*/
|
|
177
|
+
export const DEFAULT_SEMANTIC_BUDGET_TURNS = 3;
|
|
178
|
+
/**
|
|
179
|
+
* Evaluate the semantic-progress budget from existing records only. The
|
|
180
|
+
* budget is exhausted when the last `turns` Leader Runs all yielded and no
|
|
181
|
+
* WorkItem/ChangeSet/Integration/Review record changed at or after the first
|
|
182
|
+
* of those Runs. Active execution (any Run) never exhausts the budget: a
|
|
183
|
+
* slow but progressing Worker or Leader is never interrupted.
|
|
184
|
+
*/
|
|
185
|
+
export function evaluateSemanticBudget(facts, turns = DEFAULT_SEMANTIC_BUDGET_TURNS) {
|
|
186
|
+
if (facts.activeRuns.length > 0) {
|
|
187
|
+
return {
|
|
188
|
+
exhausted: false,
|
|
189
|
+
reason: "Active execution is in flight; the budget never interrupts a progressing Run.",
|
|
190
|
+
evidence: facts.activeRuns.map((run) => run.id)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const recentLeaderRuns = facts.leaderRuns
|
|
194
|
+
.filter((run) => run.status === "yielded")
|
|
195
|
+
.slice(-turns);
|
|
196
|
+
if (recentLeaderRuns.length < turns) {
|
|
197
|
+
return {
|
|
198
|
+
exhausted: false,
|
|
199
|
+
reason: `Fewer than ${turns} consecutive yielded Leader turns.`,
|
|
200
|
+
evidence: recentLeaderRuns.map((run) => run.id)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const firstStartedAt = Math.min(...recentLeaderRuns.map((run) => Date.parse(run.createdAt)));
|
|
204
|
+
const changed = latestDeliveryChangeAt(facts);
|
|
205
|
+
if (changed >= firstStartedAt) {
|
|
206
|
+
return {
|
|
207
|
+
exhausted: false,
|
|
208
|
+
reason: "A delivery record changed during the recent Leader turns.",
|
|
209
|
+
evidence: recentLeaderRuns.map((run) => run.id)
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
exhausted: true,
|
|
214
|
+
reason: `${turns} consecutive Leader turns produced no durable delivery change; record a diagnosis/yield and wait for new facts instead of creating more records.`,
|
|
215
|
+
evidence: recentLeaderRuns.map((run) => run.id)
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function latestDeliveryChangeAt(facts) {
|
|
219
|
+
const timestamps = [
|
|
220
|
+
...facts.workItems.map((item) => Date.parse(item.updatedAt)),
|
|
221
|
+
...facts.changeSets.map((changeSet) => Date.parse(changeSet.createdAt)),
|
|
222
|
+
...facts.integrations.map((attempt) => Math.max(Date.parse(attempt.updatedAt), Date.parse(attempt.endedAt ?? attempt.updatedAt))),
|
|
223
|
+
...facts.reviewRounds.map((round) => Math.max(Date.parse(round.createdAt), Date.parse(round.endedAt ?? round.createdAt)))
|
|
224
|
+
];
|
|
225
|
+
return timestamps.length === 0 ? 0 : Math.max(...timestamps);
|
|
226
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { currentWorkItemCandidate } from "../workItem/workItem.js";
|
|
3
|
+
const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
|
|
4
|
+
export function projectNextAction(facts) {
|
|
5
|
+
const { task } = facts;
|
|
6
|
+
if (task.status !== "active" && task.status !== "draft") {
|
|
7
|
+
return buildAction(facts, {
|
|
8
|
+
kind: "complete-task",
|
|
9
|
+
reason: `Task ${task.id} is ${task.status}; no further protocol action is available.`,
|
|
10
|
+
refs: [ref("task", task.id)],
|
|
11
|
+
preconditions: [
|
|
12
|
+
{ fact: `Task status is ${task.status}`, satisfied: true, ref: ref("task", task.id) }
|
|
13
|
+
]
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const openInput = facts.openInputRequests[0];
|
|
17
|
+
if (openInput !== undefined) {
|
|
18
|
+
return buildAction(facts, {
|
|
19
|
+
kind: "resolve-input",
|
|
20
|
+
reason: `Input ${openInput.id} is open and blocks protocol convergence.`,
|
|
21
|
+
refs: [ref("input-request", openInput.id)],
|
|
22
|
+
preconditions: [
|
|
23
|
+
{ fact: "Input request is open", satisfied: true, ref: ref("input-request", openInput.id) }
|
|
24
|
+
],
|
|
25
|
+
recommendedCommand: `yui task input answer ${task.id}/${openInput.id}`
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const inconsistency = detectProtocolInconsistency(facts);
|
|
29
|
+
if (inconsistency !== null) {
|
|
30
|
+
return buildAction(facts, {
|
|
31
|
+
kind: "repair-protocol-inconsistency",
|
|
32
|
+
reason: inconsistency.reason,
|
|
33
|
+
refs: inconsistency.conflicts,
|
|
34
|
+
conflicts: inconsistency.conflicts,
|
|
35
|
+
preconditions: inconsistency.conflicts.map((entry) => ({ fact: `Conflicting record ${entry.kind} ${entry.id}`, satisfied: false, ref: entry })),
|
|
36
|
+
recommendedCommand: inconsistency.recommendedCommand
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const activeLeader = facts.activeRuns.find((run) => run.roleName === "leader");
|
|
40
|
+
if (activeLeader !== undefined) {
|
|
41
|
+
return buildAction(facts, {
|
|
42
|
+
kind: "wait-for-owned-execution",
|
|
43
|
+
reason: `Leader Run ${activeLeader.id} is active; the protocol position is being executed.`,
|
|
44
|
+
refs: [ref("agent-run", activeLeader.id)],
|
|
45
|
+
preconditions: [
|
|
46
|
+
{ fact: "Leader Run is active", satisfied: true, ref: ref("agent-run", activeLeader.id) }
|
|
47
|
+
]
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const candidateReady = facts.workItems
|
|
51
|
+
.find((item) => item.status === "awaiting_acceptance");
|
|
52
|
+
if (candidateReady !== undefined) {
|
|
53
|
+
const candidate = currentWorkItemCandidate(candidateReady);
|
|
54
|
+
if (candidate !== undefined && isEmptyDirectCandidate(candidate)) {
|
|
55
|
+
const candidateRef = ref("candidate", `${candidateReady.id}/${candidate.id}`);
|
|
56
|
+
return buildAction(facts, {
|
|
57
|
+
kind: "repair-protocol-inconsistency",
|
|
58
|
+
reason: `Candidate ${candidate.id} is a direct Task-main delivery with base==head (no commits); reject it and re-dispatch real work.`,
|
|
59
|
+
refs: [ref("work-item", candidateReady.id), candidateRef],
|
|
60
|
+
conflicts: [candidateRef],
|
|
61
|
+
preconditions: [
|
|
62
|
+
{ fact: "Direct Candidate contains at least one commit", satisfied: false, ref: candidateRef }
|
|
63
|
+
],
|
|
64
|
+
recommendedCommand: `yui task work reject ${task.id}/${candidateReady.id} --summary \"empty base==head candidate\"`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const refs = [
|
|
68
|
+
ref("work-item", candidateReady.id),
|
|
69
|
+
...(candidate === undefined ? [] : [ref("candidate", `${candidateReady.id}/${candidate.id}`)])
|
|
70
|
+
];
|
|
71
|
+
return buildAction(facts, {
|
|
72
|
+
kind: "accept-or-reject-candidate",
|
|
73
|
+
reason: `Work Item ${candidateReady.id} has a Candidate awaiting Leader disposition.`,
|
|
74
|
+
refs,
|
|
75
|
+
preconditions: [
|
|
76
|
+
{ fact: "Work Item is awaiting acceptance", satisfied: true, ref: refs[0] },
|
|
77
|
+
...(candidate === undefined
|
|
78
|
+
? [{ fact: "Candidate record exists", satisfied: false }]
|
|
79
|
+
: [{ fact: "Candidate record exists", satisfied: true, ref: refs[1] }])
|
|
80
|
+
],
|
|
81
|
+
recommendedCommand: `yui task work accept ${task.id}/${candidateReady.id} --summary \"<decision>\"`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const activeWorkers = facts.activeRuns.filter((run) => run.roleName !== "leader");
|
|
85
|
+
if (activeWorkers.length > 0) {
|
|
86
|
+
return buildAction(facts, {
|
|
87
|
+
kind: "wait-for-owned-execution",
|
|
88
|
+
reason: `${activeWorkers.length} delegated Run(s) are active; wait for their delivery.`,
|
|
89
|
+
refs: activeWorkers.map((run) => ref("agent-run", run.id)),
|
|
90
|
+
preconditions: activeWorkers.map((run) => ({ fact: `Delegated Run ${run.id} is active`, satisfied: true, ref: ref("agent-run", run.id) }))
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const failedWork = facts.workItems.find((item) => item.status === "failed");
|
|
94
|
+
if (failedWork !== undefined) {
|
|
95
|
+
const failedReview = latestFailedReviewFor(facts.reviewRounds, failedWork.id);
|
|
96
|
+
if (failedReview !== undefined) {
|
|
97
|
+
return buildAction(facts, {
|
|
98
|
+
kind: "route-review-findings",
|
|
99
|
+
reason: `Work Item ${failedWork.id} failed with Review ${failedReview.id}; route its open findings into a repair wave.`,
|
|
100
|
+
refs: [ref("work-item", failedWork.id), ref("review-round", failedReview.id)],
|
|
101
|
+
preconditions: [
|
|
102
|
+
{ fact: "Work Item is failed", satisfied: true, ref: ref("work-item", failedWork.id) },
|
|
103
|
+
{ fact: "Review Round is failed", satisfied: true, ref: ref("review-round", failedReview.id) }
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return buildAction(facts, {
|
|
108
|
+
kind: "implement-current-work-item",
|
|
109
|
+
reason: `Work Item ${failedWork.id} failed without a Review verdict; retry implementation.`,
|
|
110
|
+
refs: [ref("work-item", failedWork.id)],
|
|
111
|
+
preconditions: [
|
|
112
|
+
{ fact: "Work Item is failed", satisfied: true, ref: ref("work-item", failedWork.id) }
|
|
113
|
+
],
|
|
114
|
+
recommendedCommand: `yui task work update ${task.id}/${failedWork.id} running`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const openWork = facts.workItems
|
|
118
|
+
.find((item) => OPEN_WORK_ITEM_STATUSES.has(item.status));
|
|
119
|
+
if (openWork !== undefined) {
|
|
120
|
+
return buildAction(facts, {
|
|
121
|
+
kind: "implement-current-work-item",
|
|
122
|
+
reason: `Work Item ${openWork.id} is ${openWork.status}; dispatch or continue its implementation.`,
|
|
123
|
+
refs: [ref("work-item", openWork.id)],
|
|
124
|
+
preconditions: [
|
|
125
|
+
{ fact: `Work Item is ${openWork.status}`, satisfied: true, ref: ref("work-item", openWork.id) }
|
|
126
|
+
],
|
|
127
|
+
recommendedCommand: `yui task work dispatch ${task.id}/${openWork.id}`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (facts.workItems.length === 0) {
|
|
131
|
+
return buildAction(facts, {
|
|
132
|
+
kind: "implement-current-work-item",
|
|
133
|
+
reason: `Task ${task.id} has no Work Item; create the first unit of work.`,
|
|
134
|
+
refs: [],
|
|
135
|
+
preconditions: [
|
|
136
|
+
{ fact: "At least one Work Item exists", satisfied: false },
|
|
137
|
+
{ fact: "Task is active", satisfied: task.status === "active" }
|
|
138
|
+
],
|
|
139
|
+
recommendedCommand: task.status === "draft"
|
|
140
|
+
? `yui task activate ${task.id}`
|
|
141
|
+
: `yui task work create ${task.id} \"<objective>\"`
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const uncaptured = facts.workItems.find((item) => needsChangeSetCapture(facts, item));
|
|
145
|
+
if (uncaptured !== undefined) {
|
|
146
|
+
return buildAction(facts, {
|
|
147
|
+
kind: "capture-change-set",
|
|
148
|
+
reason: `Work Item ${uncaptured.id} is completed but has no ChangeSet; capture its delivery boundary.`,
|
|
149
|
+
refs: [ref("work-item", uncaptured.id)],
|
|
150
|
+
preconditions: [
|
|
151
|
+
{ fact: "Work Item is completed", satisfied: true, ref: ref("work-item", uncaptured.id) },
|
|
152
|
+
{ fact: "ChangeSet exists for the Work Item", satisfied: false }
|
|
153
|
+
],
|
|
154
|
+
recommendedCommand: `yui task work capture ${task.id}/${uncaptured.id}`
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const unintegrated = facts.changeSets
|
|
158
|
+
.find((changeSet) => !hasCommittedIntegration(facts.integrations, changeSet.id));
|
|
159
|
+
if (unintegrated !== undefined) {
|
|
160
|
+
return buildAction(facts, {
|
|
161
|
+
kind: "integrate-change-set",
|
|
162
|
+
reason: `ChangeSet ${unintegrated.id} has no committed Integration.`,
|
|
163
|
+
refs: [ref("change-set", unintegrated.id)],
|
|
164
|
+
preconditions: [
|
|
165
|
+
{ fact: "ChangeSet exists", satisfied: true, ref: ref("change-set", unintegrated.id) },
|
|
166
|
+
{ fact: "Committed Integration references the ChangeSet", satisfied: false }
|
|
167
|
+
],
|
|
168
|
+
recommendedCommand: `yui task integration start ${task.id} --project ${unintegrated.projectId} --change-set ${unintegrated.id}`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const failedFinal = latestTaskFinalReview(facts.reviewRounds);
|
|
172
|
+
if (failedFinal !== undefined && failedFinal.status === "failed") {
|
|
173
|
+
return buildAction(facts, {
|
|
174
|
+
kind: "route-review-findings",
|
|
175
|
+
reason: `Task-final Review ${failedFinal.id} failed; route its open findings into a repair wave on one frozen head.`,
|
|
176
|
+
refs: [ref("review-round", failedFinal.id)],
|
|
177
|
+
preconditions: [
|
|
178
|
+
{ fact: "Task-final Review is failed", satisfied: true, ref: ref("review-round", failedFinal.id) }
|
|
179
|
+
]
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (task.projectBindings.length > 0 && !hasValidFinalReview(facts)) {
|
|
183
|
+
return buildAction(facts, {
|
|
184
|
+
kind: "request-final-review",
|
|
185
|
+
reason: "All Work Items are delivered but no valid Task-final Review attests the integrated head.",
|
|
186
|
+
refs: [ref("task", task.id)],
|
|
187
|
+
preconditions: [
|
|
188
|
+
{ fact: "All Work Items are terminal", satisfied: true },
|
|
189
|
+
{ fact: "Every ChangeSet is committed", satisfied: true },
|
|
190
|
+
{ fact: "Valid Task-final Review at the integrated head", satisfied: false }
|
|
191
|
+
],
|
|
192
|
+
recommendedCommand: `yui task review request ${task.id} --role <global-reviewer>`
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return buildAction(facts, {
|
|
196
|
+
kind: "complete-task",
|
|
197
|
+
reason: "The delivery chain is complete; converge the Task instead of creating successor work.",
|
|
198
|
+
refs: [ref("task", task.id)],
|
|
199
|
+
preconditions: [
|
|
200
|
+
{ fact: "All Work Items are terminal", satisfied: true },
|
|
201
|
+
...(task.projectBindings.length === 0
|
|
202
|
+
? []
|
|
203
|
+
: [
|
|
204
|
+
{ fact: "Every ChangeSet is committed", satisfied: true },
|
|
205
|
+
{ fact: "Valid Task-final Review at the integrated head", satisfied: true }
|
|
206
|
+
])
|
|
207
|
+
],
|
|
208
|
+
recommendedCommand: `yui task complete ${task.id} --summary-file -`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Stable fingerprint of the durable delivery position. It changes exactly
|
|
213
|
+
* when a delivery record changes, so the semantic-progress budget can compare
|
|
214
|
+
* positions across Leader turns without persisting anything new.
|
|
215
|
+
*/
|
|
216
|
+
export function durableStateFingerprint(facts) {
|
|
217
|
+
const parts = [
|
|
218
|
+
`task:${facts.task.status}`,
|
|
219
|
+
...facts.workItems.map((item) => `work:${item.id}:${item.status}:${item.revision}:${item.updatedAt}`),
|
|
220
|
+
...facts.changeSets.map((changeSet) => `change-set:${changeSet.id}:${changeSet.headCommit}`),
|
|
221
|
+
...facts.integrations.map((attempt) => `integration:${attempt.id}:${attempt.status}:${attempt.updatedAt}`),
|
|
222
|
+
...facts.reviewRounds.map((round) => `review:${round.id}:${round.status}:${round.endedAt ?? ""}`)
|
|
223
|
+
];
|
|
224
|
+
return createHash("sha256").update(parts.join("\n")).digest("hex");
|
|
225
|
+
}
|
|
226
|
+
function buildAction(facts, input) {
|
|
227
|
+
const fingerprintSource = [
|
|
228
|
+
input.kind,
|
|
229
|
+
...input.refs.map((entry) => `${entry.kind}:${entry.id}`)
|
|
230
|
+
].join("|");
|
|
231
|
+
return {
|
|
232
|
+
taskId: facts.task.id,
|
|
233
|
+
kind: input.kind,
|
|
234
|
+
reason: input.reason,
|
|
235
|
+
refs: input.refs,
|
|
236
|
+
preconditions: input.preconditions,
|
|
237
|
+
...(input.recommendedCommand === undefined
|
|
238
|
+
? {}
|
|
239
|
+
: { recommendedCommand: input.recommendedCommand }),
|
|
240
|
+
...(input.conflicts === undefined ? {} : { conflicts: input.conflicts }),
|
|
241
|
+
fingerprint: createHash("sha256").update(fingerprintSource).digest("hex")
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function ref(kind, id) {
|
|
245
|
+
return { kind, id };
|
|
246
|
+
}
|
|
247
|
+
function isEmptyDirectCandidate(candidate) {
|
|
248
|
+
const snapshot = candidate.taskMainSnapshot;
|
|
249
|
+
if (snapshot === undefined)
|
|
250
|
+
return false;
|
|
251
|
+
return snapshot.projects.every((project) => project.baseCommit === project.headCommit);
|
|
252
|
+
}
|
|
253
|
+
function latestFailedReviewFor(rounds, workItemId) {
|
|
254
|
+
return [...rounds]
|
|
255
|
+
.reverse()
|
|
256
|
+
.find((round) => round.workItemId === workItemId && round.status === "failed");
|
|
257
|
+
}
|
|
258
|
+
function latestTaskFinalReview(rounds) {
|
|
259
|
+
return [...rounds]
|
|
260
|
+
.reverse()
|
|
261
|
+
.find((round) => (round.scope ?? "work-item") === "task");
|
|
262
|
+
}
|
|
263
|
+
function hasCommittedIntegration(integrations, changeSetId) {
|
|
264
|
+
return integrations.some((attempt) => attempt.status === "committed" && attempt.changeSetIds.includes(changeSetId));
|
|
265
|
+
}
|
|
266
|
+
function needsChangeSetCapture(facts, item) {
|
|
267
|
+
if (item.status !== "completed")
|
|
268
|
+
return false;
|
|
269
|
+
if (facts.changeSets.some((changeSet) => changeSet.workItemId === item.id))
|
|
270
|
+
return false;
|
|
271
|
+
const candidate = item.candidates.at(-1);
|
|
272
|
+
if (candidate === undefined)
|
|
273
|
+
return false;
|
|
274
|
+
// A metadata-only direct Task-main Candidate has no WorkItem Develop
|
|
275
|
+
// workspace to capture; its boundary is the Task-main head itself.
|
|
276
|
+
if (candidate.workspace === undefined
|
|
277
|
+
&& candidate.gitSnapshot === undefined
|
|
278
|
+
&& candidate.taskMainSnapshot !== undefined) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return candidate.workspace !== undefined || candidate.gitSnapshot !== undefined;
|
|
282
|
+
}
|
|
283
|
+
function hasValidFinalReview(facts) {
|
|
284
|
+
const final = latestTaskFinalReview(facts.reviewRounds);
|
|
285
|
+
if (final === undefined || final.status !== "completed")
|
|
286
|
+
return false;
|
|
287
|
+
const reviewedCommits = new Set((final.taskCandidate?.projects ?? []).map((project) => project.commit));
|
|
288
|
+
if (reviewedCommits.size === 0)
|
|
289
|
+
return false;
|
|
290
|
+
const integratedHeads = new Set(facts.integrations
|
|
291
|
+
.filter((attempt) => attempt.status === "committed")
|
|
292
|
+
.flatMap((attempt) => facts.changeSets
|
|
293
|
+
.filter((changeSet) => attempt.changeSetIds.includes(changeSet.id))
|
|
294
|
+
.map((changeSet) => changeSet.headCommit)));
|
|
295
|
+
if (integratedHeads.size === 0)
|
|
296
|
+
return false;
|
|
297
|
+
for (const head of integratedHeads) {
|
|
298
|
+
if (!reviewedCommits.has(head))
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
function detectProtocolInconsistency(facts) {
|
|
304
|
+
const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
|
|
305
|
+
const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
|
|
306
|
+
for (const attempt of facts.integrations) {
|
|
307
|
+
if (attempt.status !== "committed")
|
|
308
|
+
continue;
|
|
309
|
+
const dangling = attempt.changeSetIds
|
|
310
|
+
.filter((id) => !changeSetIds.has(id));
|
|
311
|
+
if (dangling.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
reason: `Committed Integration ${attempt.id} references missing ChangeSet(s): ${dangling.join(", ")}.`,
|
|
314
|
+
conflicts: [
|
|
315
|
+
ref("integration-attempt", attempt.id),
|
|
316
|
+
...dangling.map((id) => ref("change-set", id))
|
|
317
|
+
]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const round of facts.reviewRounds) {
|
|
322
|
+
if (round.status !== "pending" && round.status !== "running")
|
|
323
|
+
continue;
|
|
324
|
+
const item = workItemById.get(round.workItemId);
|
|
325
|
+
if (item !== undefined && item.status === "retired") {
|
|
326
|
+
return {
|
|
327
|
+
reason: `Review ${round.id} is still ${round.status} but its Work Item ${item.id} is retired.`,
|
|
328
|
+
conflicts: [ref("review-round", round.id), ref("work-item", item.id)]
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const item of facts.workItems) {
|
|
333
|
+
if (item.status !== "awaiting_acceptance")
|
|
334
|
+
continue;
|
|
335
|
+
if (item.candidates.length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
reason: `Work Item ${item.id} is awaiting acceptance but has no Candidate record.`,
|
|
338
|
+
conflicts: [ref("work-item", item.id)]
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|