@zq-silk/yui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/cli/commandCatalog.js +28 -7
- package/dist/cli.js +50 -47
- package/dist/commands/projectCommands.js +69 -6
- package/dist/commands/taskCommands.js +639 -89
- package/dist/commands/taskContextCommand.js +78 -27
- package/dist/commands/taskNextActionCommand.js +13 -2
- package/dist/commands/taskOverviewCommand.js +21 -5
- package/dist/commands/taskUpstreamCommands.js +136 -0
- package/dist/context/runContextPack.js +184 -17
- package/dist/controller/agentRuntimeObserver.js +31 -20
- package/dist/controller/fileSchedulerStoreAdapter.js +7 -13
- package/dist/execution/candidateConvergence.js +623 -0
- package/dist/execution/executionGroup.js +255 -13
- package/dist/execution/executionHealth.js +324 -0
- package/dist/execution/resourceBroker.js +425 -0
- package/dist/executor/fileRoleLaunchPlanner.js +6 -9
- package/dist/executor/workspacePreflightClassification.js +117 -0
- package/dist/lifecycle/exactRunTerminalization.js +13 -2
- package/dist/lifecycle/taskRoleSessionReset.js +4 -2
- package/dist/repository/taskBaseFreshness.js +26 -1
- package/dist/repository/taskWorkspacePreparer.js +17 -1
- package/dist/review/reviewRound.js +27 -6
- package/dist/run/agentRun.js +2 -2
- package/dist/run/recoveryProjection.js +15 -0
- package/dist/runtime/runtimeContinuationProjection.js +7 -0
- package/dist/runtime/tmuxAdapters.js +8 -2
- package/dist/scheduler/actionability.js +169 -3
- package/dist/scheduler/activeTaskProgress.js +15 -10
- package/dist/scheduler/leaderWakeupProcessor.js +17 -1
- package/dist/scheduler/taskExecutionProjection.js +105 -8
- package/dist/scheduler/taskObservabilityProjection.js +282 -0
- package/dist/storage/migration/productionRegistry.js +14 -0
- package/dist/storage/sqliteStore.js +12 -0
- package/dist/storage/taskStore.js +1 -1
- package/dist/task/completionReadiness.js +1 -1
- package/dist/task/nextAction.js +314 -2
- package/dist/web/assets/client/components.js +116 -0
- package/dist/web/assets/client/i18n.js +66 -0
- package/dist/web/assets/client/view.js +15 -0
- package/dist/web/assets/styles/cards.js +23 -0
- package/dist/web/assets/styles/responsive.js +2 -0
- package/dist/web/webSnapshot.js +8 -2
- package/dist/workItem/workItem.js +262 -5
- package/i18n/README.zh-CN.md +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
|
|
3
|
+
import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
|
|
4
|
+
import { validateExecutionGroup } from "./executionGroup.js";
|
|
5
|
+
export const RESOURCE_BROKER_POLICY_SCHEMA_VERSION = 1;
|
|
6
|
+
/** One shared hard-stop predicate for admission, recovery, and Leader routing. */
|
|
7
|
+
export function executionStageSpendClosed(resources) {
|
|
8
|
+
return resources.deadlineReached || resources.exhaustedBudgets.length > 0;
|
|
9
|
+
}
|
|
10
|
+
const DEFAULT_ACTIVE_LANES = 4;
|
|
11
|
+
const DEFAULT_ACTIVE_LANES_PER_TASK = 2;
|
|
12
|
+
const DEFAULT_ACTIVE_LANES_PER_WORK_ITEM = 2;
|
|
13
|
+
const DEFAULT_ACTIVE_LANES_PER_GROUP = 2;
|
|
14
|
+
const DEFAULT_ACTIVE_LANES_PER_PROVIDER = 4;
|
|
15
|
+
const DEFAULT_ACTIVE_LANES_PER_AGENT = 2;
|
|
16
|
+
const DEFAULT_ACTIVE_LANES_PER_MODEL = 2;
|
|
17
|
+
const DEFAULT_QUEUED_LANES_PER_GROUP = 4;
|
|
18
|
+
export function resolveResourceBrokerPolicy(configured) {
|
|
19
|
+
const value = configured ?? {};
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
schemaVersion: RESOURCE_BROKER_POLICY_SCHEMA_VERSION,
|
|
22
|
+
maxActiveLanes: positive(value.maxActiveLanes, DEFAULT_ACTIVE_LANES, "maxActiveLanes"),
|
|
23
|
+
maxActiveLanesPerTask: positive(value.maxActiveLanesPerTask, DEFAULT_ACTIVE_LANES_PER_TASK, "maxActiveLanesPerTask"),
|
|
24
|
+
maxActiveLanesPerWorkItem: positive(value.maxActiveLanesPerWorkItem, DEFAULT_ACTIVE_LANES_PER_WORK_ITEM, "maxActiveLanesPerWorkItem"),
|
|
25
|
+
maxActiveLanesPerGroup: positive(value.maxActiveLanesPerGroup, DEFAULT_ACTIVE_LANES_PER_GROUP, "maxActiveLanesPerGroup"),
|
|
26
|
+
maxActiveLanesPerProvider: positive(value.maxActiveLanesPerProvider, DEFAULT_ACTIVE_LANES_PER_PROVIDER, "maxActiveLanesPerProvider"),
|
|
27
|
+
maxActiveLanesPerAgent: positive(value.maxActiveLanesPerAgent, DEFAULT_ACTIVE_LANES_PER_AGENT, "maxActiveLanesPerAgent"),
|
|
28
|
+
maxActiveLanesPerModel: positive(value.maxActiveLanesPerModel, DEFAULT_ACTIVE_LANES_PER_MODEL, "maxActiveLanesPerModel"),
|
|
29
|
+
maxQueuedLanesPerGroup: positive(value.maxQueuedLanesPerGroup, DEFAULT_QUEUED_LANES_PER_GROUP, "maxQueuedLanesPerGroup")
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic admission over the caller's stable request order. Capacity
|
|
34
|
+
* pressure queues a Lane; it never terminalizes that Lane or its siblings.
|
|
35
|
+
*/
|
|
36
|
+
export function planResourceAdmissions(input) {
|
|
37
|
+
const policy = resolveResourceBrokerPolicy(input.policy);
|
|
38
|
+
const active = input.active.map(validateLaneIdentity);
|
|
39
|
+
const queued = input.queued.map(validateLaneIdentity);
|
|
40
|
+
const decisions = [];
|
|
41
|
+
for (const raw of input.requests) {
|
|
42
|
+
const request = validateLaneIdentity(raw);
|
|
43
|
+
const directLimits = activeLimits(policy, active, request);
|
|
44
|
+
const activeLaneKeys = new Set(active.map(resourceLaneKey));
|
|
45
|
+
const queuedBefore = [
|
|
46
|
+
...queued,
|
|
47
|
+
...decisions.filter(({ decision }) => decision === "queued").map(({ request }) => request)
|
|
48
|
+
].filter((candidate) => !activeLaneKeys.has(resourceLaneKey(candidate)));
|
|
49
|
+
const fairReservations = directLimits.length === 0
|
|
50
|
+
? reservableOlderQueuedLanes(policy, active, queuedBefore, request)
|
|
51
|
+
: [];
|
|
52
|
+
const fairLimits = activeLimits(policy, [...active, ...fairReservations], request);
|
|
53
|
+
const limitedBy = fairLimits.length === 0
|
|
54
|
+
? directLimits
|
|
55
|
+
: directLimits.length === 0 && fairReservations.length > 0
|
|
56
|
+
? [...fairLimits, "fair-queue"]
|
|
57
|
+
: fairLimits;
|
|
58
|
+
if (limitedBy.length === 0) {
|
|
59
|
+
active.push(request);
|
|
60
|
+
decisions.push(Object.freeze({
|
|
61
|
+
request,
|
|
62
|
+
decision: "admitted",
|
|
63
|
+
limitedBy: [],
|
|
64
|
+
reason: "capacity is available at every resource scope"
|
|
65
|
+
}));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const groupQueued = queuedBefore.filter(({ taskId, executionGroupId }) => (taskId === request.taskId && executionGroupId === request.executionGroupId)).length;
|
|
69
|
+
if (groupQueued >= policy.maxQueuedLanesPerGroup) {
|
|
70
|
+
decisions.push(Object.freeze({
|
|
71
|
+
request,
|
|
72
|
+
decision: "blocked",
|
|
73
|
+
limitedBy: [...limitedBy, "group-queue"],
|
|
74
|
+
reason: `resource capacity is unavailable and the Group queue is full (${groupQueued}/${policy.maxQueuedLanesPerGroup})`
|
|
75
|
+
}));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
decisions.push(Object.freeze({
|
|
79
|
+
request,
|
|
80
|
+
decision: "queued",
|
|
81
|
+
limitedBy,
|
|
82
|
+
reason: `resource backpressure: ${limitedBy.join(", ")}`
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
return decisions;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reserve only older queued Lanes that could start now. This gives released
|
|
89
|
+
* capacity to the oldest compatible waiter without letting a Provider- or
|
|
90
|
+
* Agent-blocked head prevent independent work from using other scopes.
|
|
91
|
+
*/
|
|
92
|
+
function reservableOlderQueuedLanes(policy, active, queued, request) {
|
|
93
|
+
const projected = [...active];
|
|
94
|
+
const reserved = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const candidate of [...queued].sort(compareResourceQueueOrder)) {
|
|
97
|
+
const key = resourceLaneKey(candidate);
|
|
98
|
+
if (seen.has(key))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(key);
|
|
101
|
+
if (sameResourceLane(candidate, request)
|
|
102
|
+
|| compareResourceQueueOrder(candidate, request) >= 0
|
|
103
|
+
|| activeLimits(policy, projected, candidate).length > 0)
|
|
104
|
+
continue;
|
|
105
|
+
projected.push(candidate);
|
|
106
|
+
reserved.push(candidate);
|
|
107
|
+
}
|
|
108
|
+
return reserved;
|
|
109
|
+
}
|
|
110
|
+
function compareResourceQueueOrder(left, right) {
|
|
111
|
+
return left.requestedAt.localeCompare(right.requestedAt)
|
|
112
|
+
|| left.taskId.localeCompare(right.taskId, undefined, { numeric: true })
|
|
113
|
+
|| left.executionGroupId.localeCompare(right.executionGroupId, undefined, { numeric: true })
|
|
114
|
+
|| left.executionLaneId.localeCompare(right.executionLaneId, undefined, { numeric: true });
|
|
115
|
+
}
|
|
116
|
+
function sameResourceLane(left, right) {
|
|
117
|
+
return resourceLaneKey(left) === resourceLaneKey(right);
|
|
118
|
+
}
|
|
119
|
+
function resourceLaneKey(value) {
|
|
120
|
+
return `${value.taskId}\0${value.executionGroupId}\0${value.executionLaneId}`;
|
|
121
|
+
}
|
|
122
|
+
/** Current stage spend/completion projection used by CLI, Web, and routing. */
|
|
123
|
+
export function projectExecutionStageResources(input) {
|
|
124
|
+
const group = validateExecutionGroup(input.group);
|
|
125
|
+
const stageGroups = executionStageRetryLineage(group, input.stageGroups);
|
|
126
|
+
const stage = group.stage;
|
|
127
|
+
const resources = stage?.resources;
|
|
128
|
+
const tokens = nonNegative(input.usage.tokens, "Execution token usage");
|
|
129
|
+
const toolCalls = nonNegative(input.usage.toolCalls, "Execution tool-call usage");
|
|
130
|
+
const nowMs = input.now.getTime();
|
|
131
|
+
if (!Number.isFinite(nowMs))
|
|
132
|
+
throw new Error("Execution resource projection time is invalid.");
|
|
133
|
+
const stageStartedAt = stageGroups
|
|
134
|
+
.map(({ createdAt }) => createdAt)
|
|
135
|
+
.sort()[0] ?? group.createdAt;
|
|
136
|
+
const wallClockSeconds = Math.max(0, Math.floor((nowMs - Date.parse(stageStartedAt)) / 1_000));
|
|
137
|
+
const usable = group.lanes.filter(({ status }) => isUsable(status))
|
|
138
|
+
.sort((left, right) => (left.endedAt ?? left.updatedAt).localeCompare(right.endedAt ?? right.updatedAt));
|
|
139
|
+
const activeLaneIds = group.lanes.filter(({ status }) => status === "running").map(({ id }) => id);
|
|
140
|
+
const pendingLaneIds = group.lanes.filter(({ status }) => status === "pending").map(({ id }) => id);
|
|
141
|
+
const skippedLaneIds = group.lanes.filter(({ status }) => status === "skipped").map(({ id }) => id);
|
|
142
|
+
const quorum = resources?.quorum ?? group.lanes.length;
|
|
143
|
+
const quorumMet = usable.length >= quorum;
|
|
144
|
+
const quorumReachedAt = quorumMet
|
|
145
|
+
? (usable[quorum - 1].endedAt ?? usable[quorum - 1].updatedAt)
|
|
146
|
+
: undefined;
|
|
147
|
+
const tokensObservable = input.usage.tokensObservable ?? true;
|
|
148
|
+
const toolCallsObservable = input.usage.toolCallsObservable ?? true;
|
|
149
|
+
const exhaustedBudgets = [];
|
|
150
|
+
if (stage?.budget.maxTokens !== undefined
|
|
151
|
+
&& tokens >= stage.budget.maxTokens)
|
|
152
|
+
exhaustedBudgets.push("tokens");
|
|
153
|
+
if (toolCallsObservable && stage?.budget.maxToolCalls !== undefined
|
|
154
|
+
&& toolCalls >= stage.budget.maxToolCalls)
|
|
155
|
+
exhaustedBudgets.push("tool-calls");
|
|
156
|
+
if (stage?.budget.maxWallClockSeconds !== undefined
|
|
157
|
+
&& wallClockSeconds >= stage.budget.maxWallClockSeconds)
|
|
158
|
+
exhaustedBudgets.push("wall-clock");
|
|
159
|
+
const deadlineReached = resources === undefined
|
|
160
|
+
? false
|
|
161
|
+
: nowMs >= Date.parse(resources.deadlineAt);
|
|
162
|
+
const stragglerLaneIds = resources === undefined || quorumReachedAt === undefined
|
|
163
|
+
? []
|
|
164
|
+
: nowMs - Date.parse(quorumReachedAt) < resources.stragglerAfterSeconds * 1_000
|
|
165
|
+
? []
|
|
166
|
+
: [...activeLaneIds];
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
tokens,
|
|
169
|
+
toolCalls,
|
|
170
|
+
wallClockSeconds,
|
|
171
|
+
...(stage?.budget.maxTokens === undefined
|
|
172
|
+
? {}
|
|
173
|
+
: { tokensRemaining: Math.max(0, stage.budget.maxTokens - tokens) }),
|
|
174
|
+
...(stage?.budget.maxToolCalls === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { toolCallsRemaining: Math.max(0, stage.budget.maxToolCalls - toolCalls) }),
|
|
177
|
+
...(stage?.budget.maxWallClockSeconds === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: {
|
|
180
|
+
wallClockSecondsRemaining: Math.max(0, stage.budget.maxWallClockSeconds - wallClockSeconds)
|
|
181
|
+
}),
|
|
182
|
+
tokensObservable,
|
|
183
|
+
toolCallsObservable,
|
|
184
|
+
usableLaneCount: usable.length,
|
|
185
|
+
activeLaneIds,
|
|
186
|
+
pendingLaneIds,
|
|
187
|
+
skippedLaneIds,
|
|
188
|
+
quorumMet,
|
|
189
|
+
...(quorumReachedAt === undefined ? {} : { quorumReachedAt }),
|
|
190
|
+
deadlineReached,
|
|
191
|
+
exhaustedBudgets,
|
|
192
|
+
stragglerLaneIds
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Parallel-vs-sequential decision support. Evidence sufficiency is an explicit
|
|
197
|
+
* semantic input: budget pressure can block or defer work, but can never turn
|
|
198
|
+
* insufficient evidence into a successful early stop.
|
|
199
|
+
*/
|
|
200
|
+
export function routeExecutionStage(input) {
|
|
201
|
+
const group = validateExecutionGroup(input.group);
|
|
202
|
+
const stage = group.stage;
|
|
203
|
+
const policy = stage?.resources;
|
|
204
|
+
const marginal = input.marginalValuePercent === undefined
|
|
205
|
+
? undefined
|
|
206
|
+
: percentage(input.marginalValuePercent, "Execution marginal value");
|
|
207
|
+
const budgetPressure = executionStageSpendClosed(input.resources);
|
|
208
|
+
const marginalLow = marginal !== undefined
|
|
209
|
+
&& policy !== undefined
|
|
210
|
+
&& marginal < policy.minimumMarginalValuePercent;
|
|
211
|
+
const earlyTerminationAllowed = input.evidenceSufficient && input.resources.quorumMet;
|
|
212
|
+
const stopSpending = earlyTerminationAllowed && (budgetPressure || marginalLow);
|
|
213
|
+
const cancelPendingLaneIds = stopSpending ? input.resources.pendingLaneIds : [];
|
|
214
|
+
const retainActiveLaneIds = stopSpending ? input.resources.activeLaneIds : [];
|
|
215
|
+
if (earlyTerminationAllowed) {
|
|
216
|
+
if (input.resources.activeLaneIds.length > 0) {
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
action: "wait",
|
|
219
|
+
earlyTerminationAllowed,
|
|
220
|
+
cancelPendingLaneIds,
|
|
221
|
+
retainActiveLaneIds,
|
|
222
|
+
reason: stopSpending
|
|
223
|
+
? "quorum and sufficient evidence allow pending work to stop, but active stragglers are retained"
|
|
224
|
+
: "evidence is sufficient, but active Lanes remain inside the stage policy"
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!stopSpending && input.resources.pendingLaneIds.length > 0) {
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
action: "wait",
|
|
230
|
+
earlyTerminationAllowed,
|
|
231
|
+
cancelPendingLaneIds: [],
|
|
232
|
+
retainActiveLaneIds: [],
|
|
233
|
+
reason: "evidence is sufficient, but pending Lanes remain inside the configured marginal-value policy"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
action: "resolve",
|
|
238
|
+
earlyTerminationAllowed,
|
|
239
|
+
cancelPendingLaneIds,
|
|
240
|
+
retainActiveLaneIds,
|
|
241
|
+
reason: stopSpending
|
|
242
|
+
? "quorum and sufficient evidence make further pending spend uneconomic"
|
|
243
|
+
: "quorum and sufficient evidence are complete"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (input.resources.activeLaneIds.length > 0) {
|
|
247
|
+
return Object.freeze({
|
|
248
|
+
action: "wait",
|
|
249
|
+
earlyTerminationAllowed: false,
|
|
250
|
+
cancelPendingLaneIds: [],
|
|
251
|
+
retainActiveLaneIds: [],
|
|
252
|
+
reason: budgetPressure
|
|
253
|
+
? "evidence is insufficient; active work is retained despite budget pressure"
|
|
254
|
+
: "evidence is insufficient and active Lanes may still add evidence"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (budgetPressure) {
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
action: "blocked",
|
|
260
|
+
earlyTerminationAllowed: false,
|
|
261
|
+
cancelPendingLaneIds: [],
|
|
262
|
+
retainActiveLaneIds: [],
|
|
263
|
+
reason: "the stage budget or deadline is exhausted before evidence sufficiency"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (input.resources.pendingLaneIds.length > 0) {
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
action: "wait",
|
|
269
|
+
earlyTerminationAllowed: false,
|
|
270
|
+
cancelPendingLaneIds: [],
|
|
271
|
+
retainActiveLaneIds: [],
|
|
272
|
+
reason: "evidence is insufficient and scheduled Lanes are waiting for resource capacity"
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const capacity = group.strategy.mode === "fixed" ? group.strategy.count : group.strategy.max;
|
|
276
|
+
if (group.strategy.mode === "adaptive"
|
|
277
|
+
&& group.lanes.length < capacity
|
|
278
|
+
&& (!input.resources.quorumMet || input.disagreement === "high")) {
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
action: "expand-parallel",
|
|
281
|
+
earlyTerminationAllowed: false,
|
|
282
|
+
cancelPendingLaneIds: [],
|
|
283
|
+
retainActiveLaneIds: [],
|
|
284
|
+
reason: input.resources.quorumMet
|
|
285
|
+
? "material disagreement and available Lane capacity favor another independent route"
|
|
286
|
+
: "stage quorum is open and available Lane capacity requires another independent route"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
action: "deepen-sequential",
|
|
291
|
+
earlyTerminationAllowed: false,
|
|
292
|
+
cancelPendingLaneIds: [],
|
|
293
|
+
retainActiveLaneIds: [],
|
|
294
|
+
reason: "available evidence favors the next bounded stage over more parallel fan-out"
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/** Fold exact runtime observations into the spend dimensions Yui can prove. */
|
|
298
|
+
export function observedExecutionResourceUsage(input) {
|
|
299
|
+
const group = validateExecutionGroup(input.group);
|
|
300
|
+
const stageGroups = executionStageRetryLineage(group, input.stageGroups);
|
|
301
|
+
const groupIds = new Set(stageGroups.map(({ id }) => id));
|
|
302
|
+
const runIds = new Set([
|
|
303
|
+
...stageGroups.flatMap((candidate) => candidate.lanes
|
|
304
|
+
.flatMap(({ runId }) => runId === undefined ? [] : [runId])),
|
|
305
|
+
...(input.runs ?? []).filter((run) => (run.taskId === group.taskId
|
|
306
|
+
&& run.purpose === "execution"
|
|
307
|
+
&& run.executionGroupId !== undefined
|
|
308
|
+
&& groupIds.has(run.executionGroupId))).map(({ id }) => id)
|
|
309
|
+
]);
|
|
310
|
+
const observations = input.events.map(runtimeObservationFromTaskEvent)
|
|
311
|
+
.filter((value) => (value !== null
|
|
312
|
+
&& value.fence.taskId === group.taskId
|
|
313
|
+
&& value.fence.runId !== undefined
|
|
314
|
+
&& runIds.has(value.fence.runId)));
|
|
315
|
+
let tokens = 0;
|
|
316
|
+
let tokensObservable = true;
|
|
317
|
+
for (const runId of runIds) {
|
|
318
|
+
const usage = observations.filter(({ fence, payload }) => (fence.runId === runId && payload.usage !== undefined)).sort((left, right) => (left.receivedAt.localeCompare(right.receivedAt)
|
|
319
|
+
|| (left.sequence ?? 0) - (right.sequence ?? 0)
|
|
320
|
+
|| (left.ordinal ?? 0) - (right.ordinal ?? 0)));
|
|
321
|
+
if (usage.length === 0) {
|
|
322
|
+
tokensObservable = false;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const requestSnapshots = new Map(usage
|
|
326
|
+
.filter(({ payload }) => payload.usage.semantics === "request-context")
|
|
327
|
+
.map((observation) => [observation.semanticKey, observation.payload.usage]));
|
|
328
|
+
if (requestSnapshots.size > 0) {
|
|
329
|
+
tokens += [...requestSnapshots.values()]
|
|
330
|
+
.reduce((sum, value) => sum + value.inputTokens + value.outputTokens, 0);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const cumulative = usage
|
|
334
|
+
.filter(({ payload }) => payload.usage.semantics === "cumulative-session")
|
|
335
|
+
.map(({ payload }) => payload.usage.inputTokens + payload.usage.outputTokens);
|
|
336
|
+
if (cumulative.length >= 2 && cumulative[0] === 0) {
|
|
337
|
+
tokens += Math.max(...cumulative);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (cumulative.length >= 2) {
|
|
341
|
+
tokens += Math.max(0, Math.max(...cumulative) - cumulative[0]);
|
|
342
|
+
}
|
|
343
|
+
if (cumulative.length > 0 || usage.some(({ payload }) => (payload.usage.semantics === "remaining-context")))
|
|
344
|
+
tokensObservable = false;
|
|
345
|
+
}
|
|
346
|
+
const toolOperationIds = new Set(observations.flatMap((observation) => (observation.kind === "operation.started" && observation.payload.operation === "tool"
|
|
347
|
+
? [`${observation.fence.runId}\0${observation.payload.operationId ?? observation.semanticKey}`]
|
|
348
|
+
: [])));
|
|
349
|
+
const toolCallsObservable = observations.some(({ kind, payload }) => (kind === "operation.started" && payload.operation === "tool"));
|
|
350
|
+
return Object.freeze({
|
|
351
|
+
tokens,
|
|
352
|
+
toolCalls: toolOperationIds.size,
|
|
353
|
+
tokensObservable,
|
|
354
|
+
toolCallsObservable
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function activeLimits(policy, active, request) {
|
|
358
|
+
const checks = [
|
|
359
|
+
["home", policy.maxActiveLanes, () => true],
|
|
360
|
+
["task", policy.maxActiveLanesPerTask, (value) => value.taskId === request.taskId],
|
|
361
|
+
["work-item", policy.maxActiveLanesPerWorkItem, (value) => (request.workItemId !== undefined && value.taskId === request.taskId
|
|
362
|
+
&& value.workItemId === request.workItemId)],
|
|
363
|
+
["group", policy.maxActiveLanesPerGroup, (value) => (value.taskId === request.taskId && value.executionGroupId === request.executionGroupId)],
|
|
364
|
+
["provider", policy.maxActiveLanesPerProvider, (value) => value.providerId === request.providerId],
|
|
365
|
+
["agent", policy.maxActiveLanesPerAgent, (value) => value.agentId === request.agentId],
|
|
366
|
+
["model", policy.maxActiveLanesPerModel, (value) => modelKey(value) === modelKey(request)]
|
|
367
|
+
];
|
|
368
|
+
return checks.filter(([, limit, matches]) => active.filter(matches).length >= limit)
|
|
369
|
+
.map(([scope]) => scope);
|
|
370
|
+
}
|
|
371
|
+
function executionStageRetryLineage(group, candidates) {
|
|
372
|
+
const stage = group.stage;
|
|
373
|
+
if (stage === undefined || candidates === undefined)
|
|
374
|
+
return [group];
|
|
375
|
+
const lineage = candidates
|
|
376
|
+
.map(validateExecutionGroup)
|
|
377
|
+
.filter((candidate) => (candidate.taskId === group.taskId
|
|
378
|
+
&& candidate.purpose === "execution"
|
|
379
|
+
&& candidate.stage?.mode === stage.mode
|
|
380
|
+
&& candidate.stage.stage === stage.stage
|
|
381
|
+
&& candidate.stage.round === stage.round
|
|
382
|
+
&& candidate.stage.stageAttempt <= stage.stageAttempt
|
|
383
|
+
&& isDeepStrictEqual(candidate.stage.budget, stage.budget)
|
|
384
|
+
&& isDeepStrictEqual(candidate.stage.resources, stage.resources)));
|
|
385
|
+
if (!lineage.some(({ id }) => id === group.id))
|
|
386
|
+
lineage.push(group);
|
|
387
|
+
return lineage;
|
|
388
|
+
}
|
|
389
|
+
function validateLaneIdentity(value) {
|
|
390
|
+
requireIdentity(value.taskId, "Resource Lane Task id");
|
|
391
|
+
if (value.workItemId !== undefined)
|
|
392
|
+
requireIdentity(value.workItemId, "Resource Lane WorkItem id");
|
|
393
|
+
requireIdentity(value.executionGroupId, "Resource Lane ExecutionGroup id");
|
|
394
|
+
requireIdentity(value.executionLaneId, "Resource Lane id");
|
|
395
|
+
requireIdentity(value.providerId, "Resource Lane Provider id");
|
|
396
|
+
requireIdentity(value.agentId, "Resource Lane Agent id");
|
|
397
|
+
if (value.model !== undefined)
|
|
398
|
+
requireText(value.model, "Resource Lane model");
|
|
399
|
+
requireTimestamp(value.requestedAt, "Resource Lane request time");
|
|
400
|
+
return Object.freeze({ ...value });
|
|
401
|
+
}
|
|
402
|
+
function modelKey(value) {
|
|
403
|
+
return `${value.providerId}\0${value.model ?? "default"}`;
|
|
404
|
+
}
|
|
405
|
+
function positive(value, fallback, label) {
|
|
406
|
+
const resolved = value ?? fallback;
|
|
407
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) {
|
|
408
|
+
throw new TypeError(`${label} must be a positive integer.`);
|
|
409
|
+
}
|
|
410
|
+
return resolved;
|
|
411
|
+
}
|
|
412
|
+
function nonNegative(value, label) {
|
|
413
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
414
|
+
throw new Error(`${label} must be a non-negative integer.`);
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
function percentage(value, label) {
|
|
418
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 100) {
|
|
419
|
+
throw new Error(`${label} must be an integer from 0 to 100.`);
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
function isUsable(status) {
|
|
424
|
+
return status === "yielded" || status === "completed";
|
|
425
|
+
}
|
|
@@ -15,7 +15,7 @@ import { prefixYuiTitleInput } from "../run/runIdentity.js";
|
|
|
15
15
|
import { resolveAgentAdapter } from "./agentAdapter.js";
|
|
16
16
|
import { resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
17
17
|
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
18
|
-
import {
|
|
18
|
+
import { classifyWorkspacePreflight, formatWorkspacePreflightError } from "./workspacePreflightClassification.js";
|
|
19
19
|
import { activeLiveRoleAgentSession } from "./agentExecutor.js";
|
|
20
20
|
import { effectiveLaunchSnapshotsCompatibleForTaskMain, effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
|
|
21
21
|
import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeState, createExactControlPlaneDescriptor, createExactTaskRuntimeDescriptor, exactControlPlaneDigest, exactTaskRuntimeDescriptorPath, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
|
|
@@ -141,14 +141,11 @@ export class FileRoleLaunchPlanner {
|
|
|
141
141
|
}
|
|
142
142
|
const runWorkspace = activeRun?.workspace;
|
|
143
143
|
const main = this.store.getTaskWorkspace(task.id);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
if (durableRunWorkspace === null || !isDeepStrictEqual(durableRunWorkspace, runWorkspace)) {
|
|
150
|
-
throw new Error(`Role Run workspace is not the durable owner: ${input.taskId}/${input.roleName}.`);
|
|
151
|
-
}
|
|
144
|
+
// Quick Win (EXE-04/EXE-08): classify workspace preflight failures so
|
|
145
|
+
// split-brain state is never reported as a transient Provider failure.
|
|
146
|
+
const preflight = classifyWorkspacePreflight(this.store, task, input.roleName, activeRun === null ? null : { id: activeRun.id, workspace: activeRun.workspace });
|
|
147
|
+
if (preflight !== null) {
|
|
148
|
+
throw new Error(formatWorkspacePreflightError(preflight));
|
|
152
149
|
}
|
|
153
150
|
const assignedWorkItem = this.store.listWorkItems(task.id).find((item) => item.assignee === role.name
|
|
154
151
|
&& !["completed", "failed", "retired"].includes(item.status));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
|
|
3
|
+
function ownerLabel(owner) {
|
|
4
|
+
switch (owner.type) {
|
|
5
|
+
case "task":
|
|
6
|
+
return owner.taskId;
|
|
7
|
+
case "work-item":
|
|
8
|
+
return `${owner.taskId}/${owner.workItemId}`;
|
|
9
|
+
case "review-round":
|
|
10
|
+
return `${owner.taskId}/${owner.reviewRoundId}`;
|
|
11
|
+
case "integration-attempt":
|
|
12
|
+
return `${owner.taskId}/${owner.integrationAttemptId}`;
|
|
13
|
+
case "execution-lane":
|
|
14
|
+
return `${owner.taskId}/${owner.executionGroupId}/${owner.executionLaneId}`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Classify a workspace preflight failure. Returns `null` when the workspace
|
|
19
|
+
* is healthy and the launch may proceed.
|
|
20
|
+
*
|
|
21
|
+
* This is a read-only check: it never mutates Git state, creates Sessions, or
|
|
22
|
+
* writes recovery records.
|
|
23
|
+
*/
|
|
24
|
+
export function classifyWorkspacePreflight(store, task, roleName, activeRun) {
|
|
25
|
+
const main = store.getTaskWorkspace(task.id);
|
|
26
|
+
const bindings = task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory }));
|
|
27
|
+
// 1. Owner validation: the Task main workspace must be a durable,
|
|
28
|
+
// Task-owned ManagedWorkspace whose root matches the Task cwd and whose
|
|
29
|
+
// entries match the Project bindings.
|
|
30
|
+
const mainForDiagnostic = main;
|
|
31
|
+
if (!isTaskOwnedWorkspace(main, task.id, task.cwd, bindings)) {
|
|
32
|
+
const expected = `task-owned @ ${task.cwd ?? "(no cwd)"} with ${bindings.length} project binding(s)`;
|
|
33
|
+
const actual = mainForDiagnostic === null
|
|
34
|
+
? "no ManagedWorkspace"
|
|
35
|
+
: `${mainForDiagnostic.owner.type}/${ownerLabel(mainForDiagnostic.owner)} @ ${mainForDiagnostic.root} with ${mainForDiagnostic.entries.length} entries`;
|
|
36
|
+
return {
|
|
37
|
+
kind: "owner-invalid",
|
|
38
|
+
reason: `Task main workspace is not a durable Task-owned workspace: ${task.id}/${roleName}.`,
|
|
39
|
+
taskId: task.id,
|
|
40
|
+
roleName,
|
|
41
|
+
expected,
|
|
42
|
+
actual
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// 2. Run snapshot validation: when the active Run carries a workspace
|
|
46
|
+
// snapshot it must match the durable ManagedWorkspace exactly. A
|
|
47
|
+
// mismatch means the Run was created against a stale baseline.
|
|
48
|
+
if (activeRun?.workspace !== undefined) {
|
|
49
|
+
const durableRunWorkspace = store.getManagedWorkspace(activeRun.workspace.owner);
|
|
50
|
+
if (durableRunWorkspace === null || !isDeepStrictEqual(durableRunWorkspace, activeRun.workspace)) {
|
|
51
|
+
const diff = [];
|
|
52
|
+
if (durableRunWorkspace === null) {
|
|
53
|
+
diff.push(`durable ManagedWorkspace for owner ${JSON.stringify(activeRun.workspace.owner)} is missing`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
if (durableRunWorkspace.root !== activeRun.workspace.root) {
|
|
57
|
+
diff.push(`root: durable=${durableRunWorkspace.root} run=${activeRun.workspace.root}`);
|
|
58
|
+
}
|
|
59
|
+
if (durableRunWorkspace.entries.length !== activeRun.workspace.entries.length) {
|
|
60
|
+
diff.push(`entries: durable=${durableRunWorkspace.entries.length} run=${activeRun.workspace.entries.length}`);
|
|
61
|
+
}
|
|
62
|
+
for (const [index, entry] of activeRun.workspace.entries.entries()) {
|
|
63
|
+
const durableEntry = durableRunWorkspace.entries[index];
|
|
64
|
+
if (durableEntry === undefined) {
|
|
65
|
+
diff.push(`entry[${index}] ${entry.projectId}: missing from durable`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (durableEntry.baseCommit !== entry.baseCommit) {
|
|
69
|
+
diff.push(`entry[${index}] ${entry.projectId} baseCommit: durable=${durableEntry.baseCommit} run=${entry.baseCommit}`);
|
|
70
|
+
}
|
|
71
|
+
if (durableEntry.branch !== entry.branch) {
|
|
72
|
+
diff.push(`entry[${index}] ${entry.projectId} branch: durable=${durableEntry.branch} run=${entry.branch}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
kind: "workspace-stale",
|
|
78
|
+
reason: `Role Run workspace is not the durable owner: ${task.id}/${roleName}.`,
|
|
79
|
+
taskId: task.id,
|
|
80
|
+
roleName,
|
|
81
|
+
runId: activeRun.id,
|
|
82
|
+
diff
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Format a classification as a human-readable error message that includes the
|
|
90
|
+
* precise diff between authoritative records.
|
|
91
|
+
*/
|
|
92
|
+
export function formatWorkspacePreflightError(classification) {
|
|
93
|
+
const lines = [classification.reason];
|
|
94
|
+
switch (classification.kind) {
|
|
95
|
+
case "owner-invalid":
|
|
96
|
+
lines.push(` expected: ${classification.expected}`);
|
|
97
|
+
lines.push(` actual: ${classification.actual}`);
|
|
98
|
+
lines.push(" Use `yui task base status " + classification.taskId
|
|
99
|
+
+ "` to inspect the binding, ManagedWorkspace, and physical HEAD.");
|
|
100
|
+
break;
|
|
101
|
+
case "workspace-stale":
|
|
102
|
+
lines.push(` Run ${classification.runId} snapshot differs from the durable ManagedWorkspace:`);
|
|
103
|
+
for (const entry of classification.diff) {
|
|
104
|
+
lines.push(` - ${entry}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push(" This is a configuration error, not a transient Provider failure.");
|
|
107
|
+
lines.push(" Use `yui task base status " + classification.taskId
|
|
108
|
+
+ "` to inspect the split state, then repair or re-sync the workspace.");
|
|
109
|
+
break;
|
|
110
|
+
case "physical-drift":
|
|
111
|
+
lines.push(` Project ${classification.projectId}: expected ${classification.expectedCommit}, physical HEAD is ${classification.physicalCommit}.`);
|
|
112
|
+
lines.push(" The physical workspace has drifted from the recorded baseline.");
|
|
113
|
+
lines.push(" Use `yui task base status " + classification.taskId + "` to inspect the drift.");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
@@ -11,9 +11,10 @@ import { agentRunDeliveryReceiptId, failAgentRun, withYieldReceipt, yieldAgentRu
|
|
|
11
11
|
import { createYieldReceipt } from "../run/yieldReceipt.js";
|
|
12
12
|
import { recordExecutionLaneResult } from "../execution/executionGroup.js";
|
|
13
13
|
import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
14
|
+
import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
|
|
14
15
|
import { clearMatchingLeaderStallAttention, latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
|
|
15
16
|
import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
|
|
16
|
-
import { workItemExecutionGroupById, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
17
|
+
import { workItemExecutionGroupById, workItemOwnsUnresolvedExecutionLane, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
17
18
|
/**
|
|
18
19
|
* Validate every immutable identity and frozen Project head needed before a
|
|
19
20
|
* review Run can settle any mailbox or Round state. This is deliberately
|
|
@@ -223,6 +224,14 @@ export function terminalizeExactTaskRun(store, input, now) {
|
|
|
223
224
|
if (!matchesLaunchFence(store, sessions, input)) {
|
|
224
225
|
return obsolete(run, "launch-fence-mismatch");
|
|
225
226
|
}
|
|
227
|
+
if (runOwnsBlockingProviderContinuation(store.listEvents(input.taskId), {
|
|
228
|
+
taskId: run.taskId,
|
|
229
|
+
roleName: run.roleName,
|
|
230
|
+
runId: run.id,
|
|
231
|
+
agentId: run.effective.agentId
|
|
232
|
+
})) {
|
|
233
|
+
return obsolete(run, "provider-continuation-writer-owned");
|
|
234
|
+
}
|
|
226
235
|
// Validate the exact ReviewRound, Candidate, stored workspace, and frozen
|
|
227
236
|
// Project heads before any mailbox or Round write.
|
|
228
237
|
const reviewValidation = validateExactRunReviewRound(store, run);
|
|
@@ -480,7 +489,9 @@ function recoverExactAgentRunInTransaction(store, input) {
|
|
|
480
489
|
const terminal = terminalization.run;
|
|
481
490
|
if (terminal.purpose === "execution" && terminal.workItemId !== undefined) {
|
|
482
491
|
const item = store.getWorkItem(input.taskId, terminal.workItemId);
|
|
483
|
-
if (item !== null
|
|
492
|
+
if (item !== null
|
|
493
|
+
&& !["completed", "failed", "retired"].includes(item.status)
|
|
494
|
+
&& !workItemOwnsUnresolvedExecutionLane(item, terminal.executionGroupId, terminal.executionLaneId)) {
|
|
484
495
|
store.saveWorkItem(input.taskId, updateWorkItemStatus(item, "failed", input.now, input.reason));
|
|
485
496
|
}
|
|
486
497
|
}
|
|
@@ -7,7 +7,7 @@ import { createLeaderRecoveryNotification } from "../scheduler/operatorNotificat
|
|
|
7
7
|
import { recordLeaderFailure } from "../scheduler/leaderFailure.js";
|
|
8
8
|
import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
9
9
|
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
10
|
-
import { updateWorkItemStatus } from "../workItem/workItem.js";
|
|
10
|
+
import { updateWorkItemStatus, workItemOwnsUnresolvedExecutionLane } from "../workItem/workItem.js";
|
|
11
11
|
import { terminalizeExactTaskRun } from "./exactRunTerminalization.js";
|
|
12
12
|
/**
|
|
13
13
|
* Resets the current native generation using Yui's own persisted identities.
|
|
@@ -48,7 +48,9 @@ export function resetTaskRoleSessionGeneration(store, taskId, roleName, reason,
|
|
|
48
48
|
}
|
|
49
49
|
if (activeRun.purpose === "execution" && activeRun.workItemId !== undefined) {
|
|
50
50
|
const item = store.getWorkItem(task.id, activeRun.workItemId);
|
|
51
|
-
if (item !== null
|
|
51
|
+
if (item !== null
|
|
52
|
+
&& !["completed", "failed", "retired"].includes(item.status)
|
|
53
|
+
&& !workItemOwnsUnresolvedExecutionLane(item, activeRun.executionGroupId, activeRun.executionLaneId)) {
|
|
52
54
|
store.saveWorkItem(task.id, updateWorkItemStatus(item, "failed", now, summary));
|
|
53
55
|
}
|
|
54
56
|
}
|