@zq-silk/yui 0.10.1 → 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.
Files changed (45) hide show
  1. package/README.md +53 -0
  2. package/dist/cli/commandCatalog.js +28 -7
  3. package/dist/cli.js +50 -47
  4. package/dist/commands/projectCommands.js +69 -6
  5. package/dist/commands/taskCommands.js +639 -89
  6. package/dist/commands/taskContextCommand.js +78 -27
  7. package/dist/commands/taskNextActionCommand.js +13 -2
  8. package/dist/commands/taskOverviewCommand.js +21 -5
  9. package/dist/commands/taskUpstreamCommands.js +136 -0
  10. package/dist/context/runContextPack.js +184 -17
  11. package/dist/controller/agentRuntimeObserver.js +31 -20
  12. package/dist/controller/fileSchedulerStoreAdapter.js +7 -13
  13. package/dist/execution/candidateConvergence.js +623 -0
  14. package/dist/execution/executionGroup.js +255 -13
  15. package/dist/execution/executionHealth.js +324 -0
  16. package/dist/execution/resourceBroker.js +425 -0
  17. package/dist/executor/fileRoleLaunchPlanner.js +6 -9
  18. package/dist/executor/workspacePreflightClassification.js +117 -0
  19. package/dist/lifecycle/exactRunTerminalization.js +13 -2
  20. package/dist/lifecycle/taskRoleSessionReset.js +4 -2
  21. package/dist/repository/taskBaseFreshness.js +26 -1
  22. package/dist/repository/taskWorkspacePreparer.js +17 -1
  23. package/dist/review/reviewRound.js +27 -6
  24. package/dist/run/agentRun.js +2 -2
  25. package/dist/run/recoveryProjection.js +15 -0
  26. package/dist/runtime/runtimeContinuationProjection.js +7 -0
  27. package/dist/scheduler/actionability.js +169 -3
  28. package/dist/scheduler/activeTaskProgress.js +15 -10
  29. package/dist/scheduler/leaderWakeupProcessor.js +17 -1
  30. package/dist/scheduler/taskExecutionProjection.js +105 -8
  31. package/dist/scheduler/taskObservabilityProjection.js +282 -0
  32. package/dist/storage/migration/productionRegistry.js +14 -0
  33. package/dist/storage/sqliteStore.js +12 -0
  34. package/dist/storage/taskStore.js +1 -1
  35. package/dist/task/completionReadiness.js +1 -1
  36. package/dist/task/nextAction.js +314 -2
  37. package/dist/web/assets/client/components.js +116 -0
  38. package/dist/web/assets/client/i18n.js +66 -0
  39. package/dist/web/assets/client/view.js +15 -0
  40. package/dist/web/assets/styles/cards.js +23 -0
  41. package/dist/web/assets/styles/responsive.js +2 -0
  42. package/dist/web/webSnapshot.js +8 -2
  43. package/dist/workItem/workItem.js +262 -5
  44. package/i18n/README.zh-CN.md +42 -0
  45. 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 { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
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
- if (!isTaskOwnedWorkspace(main, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
145
- throw new Error(`Role workspace is not ready: ${input.taskId}/${input.roleName}.`);
146
- }
147
- if (runWorkspace !== undefined) {
148
- const durableRunWorkspace = this.store.getManagedWorkspace(runWorkspace.owner);
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 && !["completed", "failed", "retired"].includes(item.status)) {
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 && !["completed", "failed", "retired"].includes(item.status)) {
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
  }