@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
|
@@ -61,11 +61,29 @@ export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
|
|
|
61
61
|
const task = requireTask(store, taskId);
|
|
62
62
|
const git = options.git ?? newGitWorkspace();
|
|
63
63
|
const workspace = store.getTaskWorkspace(taskId);
|
|
64
|
+
// Collect active Run workspace snapshots once so every Project entry can
|
|
65
|
+
// report whether a live Run is pinned to a different commit.
|
|
66
|
+
const activeRuns = store.listAgentRuns(taskId)
|
|
67
|
+
.filter((run) => run.status === "active" && run.workspace !== undefined);
|
|
64
68
|
const entries = await Promise.all(task.projectBindings.map(async (binding) => {
|
|
65
69
|
const project = requireProject(store, binding.projectId);
|
|
66
70
|
const entry = workspace === null ? undefined : workspaceProjectEntry(workspace, project.id);
|
|
67
71
|
const baseCommit = await resolveBaseCommit(git, project, binding, entry);
|
|
68
72
|
const workspacePath = entry?.path ?? project.path;
|
|
73
|
+
let physicalHead;
|
|
74
|
+
try {
|
|
75
|
+
physicalHead = (await git.inspect(workspacePath)).baseCommit;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
physicalHead = undefined;
|
|
79
|
+
}
|
|
80
|
+
// Find the first active Run whose workspace snapshot covers this Project.
|
|
81
|
+
const runSnapshot = activeRuns.find((run) => {
|
|
82
|
+
if (run.workspace === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
const runEntry = workspaceProjectEntry(run.workspace, project.id);
|
|
85
|
+
return runEntry !== undefined;
|
|
86
|
+
});
|
|
69
87
|
const provenance = latestBaseProvenance(store.listEvents(taskId), project.id);
|
|
70
88
|
const tracked = await resolveTracked(git, project, workspacePath, options.refresh === true);
|
|
71
89
|
const status = await classifyStatus(git, workspacePath, project, baseCommit, tracked);
|
|
@@ -80,6 +98,13 @@ export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
|
|
|
80
98
|
source: tracked?.source ?? (project.remoteUrl === undefined ? "not-applicable" : "compatibility-projection"),
|
|
81
99
|
workspacePath,
|
|
82
100
|
workspaceClean,
|
|
101
|
+
...(physicalHead === undefined ? {} : { physicalHead }),
|
|
102
|
+
...(runSnapshot?.workspace === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: {
|
|
105
|
+
runSnapshotCommit: workspaceProjectEntry(runSnapshot.workspace, project.id)?.baseCommit,
|
|
106
|
+
runSnapshotRunId: runSnapshot.id
|
|
107
|
+
}),
|
|
83
108
|
...(tracked === undefined ? {} : {
|
|
84
109
|
trackedRef: tracked.ref,
|
|
85
110
|
trackedCommit: tracked.commit,
|
|
@@ -136,7 +161,7 @@ export function renderTaskBaseFreshnessReport(report) {
|
|
|
136
161
|
`Task base freshness: ${report.taskId} (source: ${report.refreshed ? "remote refresh" : "local tracking"})`
|
|
137
162
|
];
|
|
138
163
|
for (const entry of report.entries) {
|
|
139
|
-
lines.push(`- ${entry.directory}: ${entry.projectId} @ ${entry.baseCommit}`, ` status: ${entry.status}; workspace: ${entry.workspaceClean === null ? "unknown" : entry.workspaceClean ? "clean" : "dirty"}`, ` observed remote: ${entry.observedRemoteUrl ?? "-"}`, ` tracked: ${entry.trackedCommit ?? "-"}${entry.trackedRef === undefined ? "" : ` (${entry.trackedRef})`}`, ` observed: ${entry.observedTrackingCommit ?? "-"}${entry.observedAt === undefined ? "" : ` at ${entry.observedAt}`}`);
|
|
164
|
+
lines.push(`- ${entry.directory}: ${entry.projectId} @ ${entry.baseCommit}`, ` status: ${entry.status}; workspace: ${entry.workspaceClean === null ? "unknown" : entry.workspaceClean ? "clean" : "dirty"}`, ` physical HEAD: ${entry.physicalHead ?? "-"}`, ` Run snapshot: ${entry.runSnapshotCommit ?? "-"}${entry.runSnapshotRunId === undefined ? "" : ` (${entry.runSnapshotRunId})`}`, ` observed remote: ${entry.observedRemoteUrl ?? "-"}`, ` tracked: ${entry.trackedCommit ?? "-"}${entry.trackedRef === undefined ? "" : ` (${entry.trackedRef})`}`, ` observed: ${entry.observedTrackingCommit ?? "-"}${entry.observedAt === undefined ? "" : ` at ${entry.observedAt}`}`);
|
|
140
165
|
if (entry.risk !== undefined)
|
|
141
166
|
lines.push(` risk: ${entry.risk}`);
|
|
142
167
|
if (entry.error !== undefined)
|
|
@@ -1028,7 +1028,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1028
1028
|
: this.store.getReviewRound(taskId, lineage.reviewRoundId)?.executionGroup;
|
|
1029
1029
|
const lane = group?.lanes.find(({ id }) => id === executionLaneId);
|
|
1030
1030
|
if (group === undefined
|
|
1031
|
-
|| lane === undefined || !["completed", "failed", "yielded"].includes(lane.status)) {
|
|
1031
|
+
|| lane === undefined || !["completed", "failed", "yielded", "skipped"].includes(lane.status)) {
|
|
1032
1032
|
throw new Error(`Execution Lane is not terminally resolved: ${taskId}/${executionLaneId}.`);
|
|
1033
1033
|
}
|
|
1034
1034
|
const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
|
|
@@ -1895,6 +1895,22 @@ export class FileTaskWorkspacePreparer {
|
|
|
1895
1895
|
}
|
|
1896
1896
|
async #rebuildTaskWorkspaceLocked(task, options) {
|
|
1897
1897
|
if (task.workspaceIdentity !== undefined) {
|
|
1898
|
+
// Quick Win (EXE-08): an identity-bearing Task cannot silently "resume"
|
|
1899
|
+
// when --latest is requested. The resume branch only cleans legacy
|
|
1900
|
+
// refs and reclaims orphans; it never re-pins the baseline. Reporting
|
|
1901
|
+
// success here would manufacture a split-brain state where the physical
|
|
1902
|
+
// Task branch moves but the binding, ManagedWorkspace, and Run snapshot
|
|
1903
|
+
// stay on the old commit. Refuse explicitly and point at the correct
|
|
1904
|
+
// tools instead.
|
|
1905
|
+
if (options.latestRemote === true) {
|
|
1906
|
+
throw new Error(`Task ${task.id} already owns a workspace identity; `
|
|
1907
|
+
+ "`task rebuild --latest` cannot re-pin its baseline in place. "
|
|
1908
|
+
+ "Use `yui task base status " + task.id + "` to inspect the current "
|
|
1909
|
+
+ "binding, ManagedWorkspace, physical HEAD, and Run snapshot. "
|
|
1910
|
+
+ "For an Active Task, integrate upstream changes through the "
|
|
1911
|
+
+ "normal Leader-driven integration path; for a Draft Task with no "
|
|
1912
|
+
+ "execution evidence, recreate the Task workspace.");
|
|
1913
|
+
}
|
|
1898
1914
|
const current = this.store.getTaskWorkspace(task.id);
|
|
1899
1915
|
if (current !== null && current.owner.type === "task") {
|
|
1900
1916
|
await ensureWorkspaceView(current.root, current.entries);
|
|
@@ -138,7 +138,7 @@ export function finishReviewRound(round, status, summary, now, result = {}) {
|
|
|
138
138
|
* returns to pending so infrastructure retries do not manufacture a new
|
|
139
139
|
* semantic ReviewRound or duplicate findings.
|
|
140
140
|
*/
|
|
141
|
-
export function retryTaskReviewRound(round) {
|
|
141
|
+
export function retryTaskReviewRound(round, executionLaneId) {
|
|
142
142
|
validateReviewRound(round);
|
|
143
143
|
if ((round.scope ?? "work-item") !== "task") {
|
|
144
144
|
throw new Error(`Only a Task-final ReviewRound can be retried in place: ${round.id}.`);
|
|
@@ -148,7 +148,7 @@ export function retryTaskReviewRound(round) {
|
|
|
148
148
|
}
|
|
149
149
|
const retryExecutionGroup = round.executionGroup === undefined
|
|
150
150
|
? undefined
|
|
151
|
-
: retryReviewExecutionGroup(round);
|
|
151
|
+
: retryReviewExecutionGroup(round, executionLaneId);
|
|
152
152
|
return validateReviewRound({
|
|
153
153
|
schemaVersion: round.schemaVersion,
|
|
154
154
|
id: round.id,
|
|
@@ -186,11 +186,32 @@ export function retryTaskReviewRound(round) {
|
|
|
186
186
|
createdAt: round.createdAt
|
|
187
187
|
});
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
/** Keep a running panel Round active while retrying only its exact failed Lane. */
|
|
190
|
+
export function retryRunningReviewExecutionLane(round, executionLaneId, runId, now) {
|
|
191
|
+
validateReviewRound(round);
|
|
192
|
+
if (round.status !== "running" || round.executionGroup === undefined) {
|
|
193
|
+
throw new Error(`ReviewRound ${round.id} has no running ExecutionGroup.`);
|
|
194
|
+
}
|
|
195
|
+
const lane = round.executionGroup.lanes.find(({ id }) => id === executionLaneId);
|
|
196
|
+
if (lane === undefined || lane.status !== "failed" || lane.runId !== runId) {
|
|
197
|
+
throw new Error(`Review retry does not target the current failed Lane attempt: `
|
|
198
|
+
+ `${round.executionGroup.id}/${executionLaneId}/${runId}.`);
|
|
199
|
+
}
|
|
200
|
+
return updateReviewExecutionGroup(round, retryReviewExecutionGroup(round, executionLaneId, now));
|
|
201
|
+
}
|
|
202
|
+
function retryReviewExecutionGroup(round, executionLaneId, retryAt) {
|
|
190
203
|
const previous = round.executionGroup;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
if (executionLaneId !== undefined) {
|
|
205
|
+
const exactLane = previous.lanes.find(({ id }) => id === executionLaneId);
|
|
206
|
+
if (exactLane === undefined || exactLane.status !== "failed") {
|
|
207
|
+
throw new Error(`Review retry Lane is not failed: ${previous.id}/${executionLaneId}.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const now = retryAt ?? new Date(Date.parse(round.endedAt ?? round.createdAt));
|
|
211
|
+
const lanes = previous.lanes.map((lane) => (lane.status === "failed"
|
|
212
|
+
&& (executionLaneId === undefined || lane.id === executionLaneId)
|
|
213
|
+
? resetReviewExecutionLane(previous, lane.id, now)
|
|
214
|
+
: lane));
|
|
194
215
|
return validateExecutionGroup({
|
|
195
216
|
...previous,
|
|
196
217
|
lanes,
|
package/dist/run/agentRun.js
CHANGED
|
@@ -175,8 +175,8 @@ export function validateAgentRun(run) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
if (run.purpose === "review") {
|
|
178
|
-
if (run.
|
|
179
|
-
throw new Error("A review Agent run requires
|
|
178
|
+
if (run.reviewRoundId === undefined) {
|
|
179
|
+
throw new Error("A review Agent run requires a ReviewRound reference.");
|
|
180
180
|
}
|
|
181
181
|
if (run.workspace === undefined
|
|
182
182
|
|| !((run.workspace.owner.type === "review-round"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { latestRunDurableProgressAt } from "../scheduler/roleRunStall.js";
|
|
3
|
+
import { actionableExecutionLaneRecoveries } from "../execution/executionHealth.js";
|
|
3
4
|
export const RUN_RECOVERY_ACTIONS = [
|
|
4
5
|
"diagnose",
|
|
5
6
|
"retry",
|
|
@@ -25,6 +26,20 @@ export function readRunRecoveryFacts(store, taskId, runId) {
|
|
|
25
26
|
latestProviderObservation: latestRunProviderObservation(store.listEvents(taskId), runId)
|
|
26
27
|
};
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the exact live-Run recovery plans referenced by Lane health. Failed
|
|
31
|
+
* terminal Lanes use `task run retry` directly and therefore need no live-Run
|
|
32
|
+
* recovery projection here.
|
|
33
|
+
*/
|
|
34
|
+
export function projectExecutionLaneRunRecoveries(store, taskId, groups) {
|
|
35
|
+
const runIds = new Set(actionableExecutionLaneRecoveries(groups).flatMap((lane) => (lane.runId === undefined || lane.recovery === "retry-new-agent-run"
|
|
36
|
+
? []
|
|
37
|
+
: [lane.runId])));
|
|
38
|
+
return [...runIds].flatMap((runId) => {
|
|
39
|
+
const facts = readRunRecoveryFacts(store, taskId, runId);
|
|
40
|
+
return facts === null ? [] : [projectRunRecovery(facts)];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
28
43
|
/**
|
|
29
44
|
* Latest Provider observation for a Run. Provider timestamps are evidence:
|
|
30
45
|
* they explain why a stale fence was supplied but never authorize recovery.
|
|
@@ -32,3 +32,10 @@ export function projectProviderContinuations(events) {
|
|
|
32
32
|
export function blockingProviderContinuations(events) {
|
|
33
33
|
return projectProviderContinuations(events).filter((entry) => (continuationOwnsWriterUmbrella(entry) || entry.identityConflict));
|
|
34
34
|
}
|
|
35
|
+
/** Exact Run-scoped writer fence shared by terminalization and retry paths. */
|
|
36
|
+
export function runOwnsBlockingProviderContinuation(events, owner) {
|
|
37
|
+
return blockingProviderContinuations(events).some((continuation) => (continuation.taskId === owner.taskId
|
|
38
|
+
&& continuation.roleName === owner.roleName
|
|
39
|
+
&& continuation.runId === owner.runId
|
|
40
|
+
&& continuation.identity.accountScope === owner.agentId));
|
|
41
|
+
}
|
|
@@ -281,11 +281,14 @@ export class TmuxSessionHost {
|
|
|
281
281
|
return binding;
|
|
282
282
|
}
|
|
283
283
|
const broker = launchBrokerForHome(yuiHome);
|
|
284
|
+
const sessionManifest = planned.launch.env.YUI_SESSION_MANIFEST;
|
|
284
285
|
const frozenControlPlane = planned.launch.env[YUI_CONTROL_PLANE_DESCRIPTOR];
|
|
285
286
|
const frozenTaskRuntime = planned.launch.env[YUI_TASK_RUNTIME_DESCRIPTOR];
|
|
286
287
|
if (request.owner.scope === "task" && request.runId !== undefined
|
|
287
|
-
&& (
|
|
288
|
-
|
|
288
|
+
&& (sessionManifest === undefined
|
|
289
|
+
|| frozenControlPlane === undefined
|
|
290
|
+
|| frozenTaskRuntime === undefined)) {
|
|
291
|
+
throw new Error("Managed Task Agent Host launch is missing its Session Manifest or frozen control descriptors.");
|
|
289
292
|
}
|
|
290
293
|
const reservation = broker.reserve(Object.freeze({
|
|
291
294
|
schemaVersion: 1,
|
|
@@ -324,6 +327,9 @@ export class TmuxSessionHost {
|
|
|
324
327
|
...(planned.launch.env.YUI_WORKSPACE === undefined
|
|
325
328
|
? {}
|
|
326
329
|
: { YUI_WORKSPACE: planned.launch.env.YUI_WORKSPACE }),
|
|
330
|
+
...(sessionManifest === undefined
|
|
331
|
+
? {}
|
|
332
|
+
: { YUI_SESSION_MANIFEST: sessionManifest }),
|
|
327
333
|
...(frozenControlPlane === undefined
|
|
328
334
|
? {}
|
|
329
335
|
: { [YUI_CONTROL_PLANE_DESCRIPTOR]: frozenControlPlane }),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
|
|
2
3
|
import { isDurableJobTerminal } from "../job/durableJob.js";
|
|
3
4
|
import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
|
|
5
|
+
import { executionStageSpendClosed, observedExecutionResourceUsage, projectExecutionStageResources } from "../execution/resourceBroker.js";
|
|
4
6
|
/**
|
|
5
7
|
* Canonical SHA-256 digest over the normalized actionable facts. Pure and
|
|
6
8
|
* deterministic: the same facts always produce the same digest regardless of
|
|
@@ -19,6 +21,92 @@ export function computeActionabilityDigest(input) {
|
|
|
19
21
|
}
|
|
20
22
|
const TERMINAL_WORK_ITEM_STATUSES = new Set(["completed", "retired"]);
|
|
21
23
|
const CONSUMED_INTEGRATION_STATUSES = new Set(["committed", "superseded"]);
|
|
24
|
+
/** Exact durable Broker queue shared by admission and wake actionability. */
|
|
25
|
+
export function projectQueuedResourceLaneIdentities(store, now) {
|
|
26
|
+
return store.listActiveTaskIds().flatMap((taskId) => {
|
|
27
|
+
const taskRuns = store.listAgentRuns(taskId);
|
|
28
|
+
const taskEvents = store.listEvents(taskId);
|
|
29
|
+
const workItemLanes = store.listWorkItems(taskId).flatMap((item) => {
|
|
30
|
+
if (item.status !== "running")
|
|
31
|
+
return [];
|
|
32
|
+
const group = currentWorkItemExecutionGroup(item);
|
|
33
|
+
if (group === undefined || group.resolution !== undefined)
|
|
34
|
+
return [];
|
|
35
|
+
if (group.stage !== undefined) {
|
|
36
|
+
const resources = projectExecutionStageResources({
|
|
37
|
+
group,
|
|
38
|
+
stageGroups: item.executionGroups,
|
|
39
|
+
usage: observedExecutionResourceUsage({
|
|
40
|
+
group,
|
|
41
|
+
stageGroups: item.executionGroups,
|
|
42
|
+
runs: taskRuns,
|
|
43
|
+
events: taskEvents
|
|
44
|
+
}),
|
|
45
|
+
now
|
|
46
|
+
});
|
|
47
|
+
if (executionStageSpendClosed(resources))
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return group.lanes.flatMap((lane) => {
|
|
51
|
+
if (lane.status !== "pending"
|
|
52
|
+
|| lane.effective === undefined
|
|
53
|
+
|| lane.runId !== undefined)
|
|
54
|
+
return [];
|
|
55
|
+
return [{
|
|
56
|
+
taskId,
|
|
57
|
+
workItemId: item.id,
|
|
58
|
+
executionGroupId: group.id,
|
|
59
|
+
executionLaneId: lane.id,
|
|
60
|
+
providerId: lane.effective.adapterId,
|
|
61
|
+
agentId: lane.effective.agentId,
|
|
62
|
+
...(lane.effective.model === undefined ? {} : { model: lane.effective.model }),
|
|
63
|
+
requestedAt: lane.updatedAt
|
|
64
|
+
}];
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
const reviewLanes = store.listReviewRounds(taskId).flatMap((round) => {
|
|
68
|
+
if (round.status !== "pending" && round.status !== "running")
|
|
69
|
+
return [];
|
|
70
|
+
const group = round.executionGroup;
|
|
71
|
+
if (group === undefined || group.resolution !== undefined)
|
|
72
|
+
return [];
|
|
73
|
+
return group.lanes.flatMap((lane) => {
|
|
74
|
+
if (lane.status !== "pending"
|
|
75
|
+
|| lane.effective === undefined
|
|
76
|
+
|| lane.runId !== undefined)
|
|
77
|
+
return [];
|
|
78
|
+
return [{
|
|
79
|
+
taskId,
|
|
80
|
+
...(round.workItemId === undefined ? {} : { workItemId: round.workItemId }),
|
|
81
|
+
executionGroupId: group.id,
|
|
82
|
+
executionLaneId: lane.id,
|
|
83
|
+
providerId: lane.effective.adapterId,
|
|
84
|
+
agentId: lane.effective.agentId,
|
|
85
|
+
...(lane.effective.model === undefined ? {} : { model: lane.effective.model }),
|
|
86
|
+
requestedAt: lane.updatedAt
|
|
87
|
+
}];
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return [...workItemLanes, ...reviewLanes];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* True only when the Task still owns a Resource-Broker-queued Lane that a
|
|
95
|
+
* later Leader dispatch may start. Terminal owners retain their immutable
|
|
96
|
+
* Group history, but can never reserve live queue capacity.
|
|
97
|
+
*/
|
|
98
|
+
export function hasDispatchableQueuedResourceLane(store, taskId) {
|
|
99
|
+
const workItemGroups = (store.listWorkItems?.(taskId) ?? [])
|
|
100
|
+
.filter(({ status }) => status === "running")
|
|
101
|
+
.flatMap(({ executionGroups }) => executionGroups);
|
|
102
|
+
const reviewGroups = (store.listReviewRounds?.(taskId) ?? [])
|
|
103
|
+
.filter(({ status }) => status === "pending" || status === "running")
|
|
104
|
+
.flatMap(({ executionGroup }) => executionGroup === undefined ? [] : [executionGroup]);
|
|
105
|
+
return [...workItemGroups, ...reviewGroups].some((group) => (group.resolution === undefined
|
|
106
|
+
&& group.lanes.some((lane) => (lane.status === "pending"
|
|
107
|
+
&& lane.effective !== undefined
|
|
108
|
+
&& lane.runId === undefined))));
|
|
109
|
+
}
|
|
22
110
|
/**
|
|
23
111
|
* Fold a Task's durable records into the normalized actionable facts. This
|
|
24
112
|
* function only reads; it never starts a Controller, queues a wake, writes a
|
|
@@ -41,7 +129,7 @@ const CONSUMED_INTEGRATION_STATUSES = new Set(["committed", "superseded"]);
|
|
|
41
129
|
* an unchanged blocker, read-only status requests, and waits already owned by
|
|
42
130
|
* an active Worker/Reviewer/Job (the active-Run facts above capture ownership).
|
|
43
131
|
*/
|
|
44
|
-
export function collectTaskActionability(store, taskId) {
|
|
132
|
+
export function collectTaskActionability(store, taskId, now = new Date()) {
|
|
45
133
|
const task = store.getTask(taskId);
|
|
46
134
|
if (task === null) {
|
|
47
135
|
throw new Error(`Task not found for actionability projection: ${taskId}.`);
|
|
@@ -59,15 +147,93 @@ export function collectTaskActionability(store, taskId) {
|
|
|
59
147
|
].join("|")
|
|
60
148
|
});
|
|
61
149
|
}
|
|
62
|
-
|
|
150
|
+
const workItems = store.listWorkItems?.(taskId) ?? [];
|
|
151
|
+
const reviewRounds = store.listReviewRounds?.(taskId) ?? [];
|
|
152
|
+
for (const item of workItems) {
|
|
63
153
|
if (TERMINAL_WORK_ITEM_STATUSES.has(item.status))
|
|
64
154
|
continue;
|
|
65
155
|
facts.push({
|
|
66
156
|
key: `work-item:${item.id}`,
|
|
67
157
|
value: `${item.status}|${item.updatedAt}`
|
|
68
158
|
});
|
|
159
|
+
for (const group of item.executionGroups) {
|
|
160
|
+
if (group.resolution !== undefined || group.stage?.resources === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
facts.push({
|
|
163
|
+
key: `resource-deadline:${group.id}`,
|
|
164
|
+
value: now.getTime() >= Date.parse(group.stage.resources.deadlineAt)
|
|
165
|
+
? "reached"
|
|
166
|
+
: "open"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// A Resource-Broker-queued Lane is durable but deliberately has no active
|
|
171
|
+
// Run. Include the global capacity and fair-queue projection only for Tasks
|
|
172
|
+
// that are actually queued, so either capacity or an older reservation being
|
|
173
|
+
// released changes their digest without polling or a second scheduler.
|
|
174
|
+
const queueProjectionAvailable = store.listActiveTaskIds !== undefined
|
|
175
|
+
&& store.listWorkItems !== undefined
|
|
176
|
+
&& store.listReviewRounds !== undefined
|
|
177
|
+
&& store.listEvents !== undefined;
|
|
178
|
+
const queuedResourceLanes = queueProjectionAvailable
|
|
179
|
+
? projectQueuedResourceLaneIdentities({
|
|
180
|
+
listActiveTaskIds: () => store.listActiveTaskIds(),
|
|
181
|
+
listAgentRuns: (candidateTaskId) => store.listAgentRuns(candidateTaskId),
|
|
182
|
+
listWorkItems: (candidateTaskId) => store.listWorkItems(candidateTaskId),
|
|
183
|
+
listReviewRounds: (candidateTaskId) => store.listReviewRounds(candidateTaskId),
|
|
184
|
+
listEvents: (candidateTaskId) => store.listEvents(candidateTaskId)
|
|
185
|
+
}, now)
|
|
186
|
+
: [];
|
|
187
|
+
const hasQueuedResourceLane = queueProjectionAvailable
|
|
188
|
+
? queuedResourceLanes.some((lane) => lane.taskId === taskId)
|
|
189
|
+
: hasDispatchableQueuedResourceLane(store, taskId);
|
|
190
|
+
if (hasQueuedResourceLane && store.listActiveTaskIds !== undefined) {
|
|
191
|
+
const activeResourceScopes = store.listActiveTaskIds().flatMap((activeTaskId) => (operationalTaskRecords(store.listAgentRuns(activeTaskId), store.listEvents?.(activeTaskId) ?? [], "agent-run").flatMap((run) => (run.status !== "active"
|
|
192
|
+
|| run.executionGroupId === undefined
|
|
193
|
+
|| run.executionLaneId === undefined
|
|
194
|
+
? []
|
|
195
|
+
: [
|
|
196
|
+
"home",
|
|
197
|
+
`task:${activeTaskId}`,
|
|
198
|
+
...(run.workItemId === undefined
|
|
199
|
+
? []
|
|
200
|
+
: [`work-item:${activeTaskId}/${run.workItemId}`]),
|
|
201
|
+
`group:${activeTaskId}/${run.executionGroupId}`,
|
|
202
|
+
`provider:${run.effective.adapterId}`,
|
|
203
|
+
`agent:${run.effective.agentId}`,
|
|
204
|
+
`model:${run.effective.adapterId}/${run.effective.model ?? "default"}`
|
|
205
|
+
]))));
|
|
206
|
+
const activeResourceCounts = new Map();
|
|
207
|
+
for (const scope of activeResourceScopes) {
|
|
208
|
+
activeResourceCounts.set(scope, (activeResourceCounts.get(scope) ?? 0) + 1);
|
|
209
|
+
}
|
|
210
|
+
facts.push({
|
|
211
|
+
key: "resource-broker:active-capacity",
|
|
212
|
+
value: [...activeResourceCounts]
|
|
213
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
214
|
+
.map(([scope, count]) => `${scope}=${count}`)
|
|
215
|
+
.join("|")
|
|
216
|
+
});
|
|
217
|
+
if (queueProjectionAvailable) {
|
|
218
|
+
facts.push({
|
|
219
|
+
key: "resource-broker:fair-queue",
|
|
220
|
+
value: queuedResourceLanes
|
|
221
|
+
.map((lane) => [
|
|
222
|
+
lane.requestedAt,
|
|
223
|
+
lane.taskId,
|
|
224
|
+
lane.workItemId ?? "",
|
|
225
|
+
lane.executionGroupId,
|
|
226
|
+
lane.executionLaneId,
|
|
227
|
+
lane.providerId,
|
|
228
|
+
lane.agentId,
|
|
229
|
+
lane.model ?? "default"
|
|
230
|
+
].join("|"))
|
|
231
|
+
.sort()
|
|
232
|
+
.join("\n")
|
|
233
|
+
});
|
|
234
|
+
}
|
|
69
235
|
}
|
|
70
|
-
for (const round of
|
|
236
|
+
for (const round of reviewRounds) {
|
|
71
237
|
if (round.status === "completed")
|
|
72
238
|
continue;
|
|
73
239
|
facts.push({
|
|
@@ -2,7 +2,7 @@ import { selectedActiveSchedulerTasks, isSchedulerTaskWorkspaceReady } from "./p
|
|
|
2
2
|
import { queueLeaderWakeup } from "./wakeupQueue.js";
|
|
3
3
|
import { wakeReason } from "./wakeReason.js";
|
|
4
4
|
import { projectTaskExecution } from "./taskExecutionProjection.js";
|
|
5
|
-
import { collectTaskActionability, computeActionabilityDigest, decideOrphanWake } from "./actionability.js";
|
|
5
|
+
import { collectTaskActionability, computeActionabilityDigest, decideOrphanWake, hasDispatchableQueuedResourceLane } from "./actionability.js";
|
|
6
6
|
import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
|
|
7
7
|
/**
|
|
8
8
|
* Repairs an active Task that has no durable owner capable of advancing it.
|
|
@@ -26,6 +26,7 @@ export function repairOrphanedActiveTasks(store, now, selection) {
|
|
|
26
26
|
return run === null ? [] : [run];
|
|
27
27
|
});
|
|
28
28
|
const hasInFlightTurn = roles.some((role) => store.hasInFlightTurn(task.id, role.name));
|
|
29
|
+
const hasLeaderInFlightTurn = store.hasInFlightTurn(task.id, "leader");
|
|
29
30
|
const leaderTarget = { kind: "role", taskId: task.id, roleName: "leader" };
|
|
30
31
|
const leaderMailbox = store.getWorkMailbox(leaderTarget);
|
|
31
32
|
const projection = projectTaskExecution({
|
|
@@ -37,19 +38,23 @@ export function repairOrphanedActiveTasks(store, now, selection) {
|
|
|
37
38
|
leaderFailure: store.getLeaderFailure(task.id),
|
|
38
39
|
operatorNotification: store.getOperatorNotification(task.id)
|
|
39
40
|
});
|
|
40
|
-
|
|
41
|
+
const queuedAlongsideActiveSibling = hasDispatchableQueuedResourceLane(store, task.id)
|
|
42
|
+
&& !activeRuns.some(({ roleName }) => roleName === "leader")
|
|
43
|
+
&& activeRuns.some(({ roleName }) => roleName !== "leader")
|
|
44
|
+
&& projection.status === "waiting-on-agents";
|
|
45
|
+
if ((queuedAlongsideActiveSibling ? hasLeaderInFlightTurn : hasInFlightTurn)
|
|
41
46
|
|| store.hasOpenInputRequest(task.id)
|
|
42
47
|
|| store.getLeaderFailure(task.id) !== null
|
|
43
48
|
|| store.getOperatorNotification(task.id) !== null
|
|
44
|
-
|| projection.status !== "needs-leader-action"
|
|
49
|
+
|| (projection.status !== "needs-leader-action" && !queuedAlongsideActiveSibling)
|
|
45
50
|
|| hasUnclaimedLeaderWork(store, task.id, leaderMailbox)) {
|
|
46
51
|
continue;
|
|
47
52
|
}
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
if (projection.reason === "no-executor") {
|
|
52
|
-
if (admitOrphanWake(store, task.id) === "suppress")
|
|
53
|
+
// Digest admission covers both an ownerless Task and a resource-queued
|
|
54
|
+
// Task with active siblings. The latter wakes only when capacity/deadline
|
|
55
|
+
// actionability changes, so repeated full scans remain silent.
|
|
56
|
+
if (projection.reason === "no-executor" || queuedAlongsideActiveSibling) {
|
|
57
|
+
if (admitOrphanWake(store, task.id, now) === "suppress")
|
|
53
58
|
continue;
|
|
54
59
|
}
|
|
55
60
|
const taskWorkspace = store.getTaskWorkspace(task.id);
|
|
@@ -76,10 +81,10 @@ export function repairOrphanedActiveTasks(store, now, selection) {
|
|
|
76
81
|
* `"suppress"` without writing anything when the digest is unchanged since
|
|
77
82
|
* the last waiting/blocked Leader Run. Computation errors fail open.
|
|
78
83
|
*/
|
|
79
|
-
function admitOrphanWake(store, taskId) {
|
|
84
|
+
function admitOrphanWake(store, taskId, now) {
|
|
80
85
|
let digest;
|
|
81
86
|
try {
|
|
82
|
-
const input = collectTaskActionability(store, taskId);
|
|
87
|
+
const input = collectTaskActionability(store, taskId, now);
|
|
83
88
|
digest = computeActionabilityDigest(input);
|
|
84
89
|
}
|
|
85
90
|
catch (error) {
|
|
@@ -126,7 +126,23 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
|
|
|
126
126
|
const resumableSession = hasNativeSession(existingSession)
|
|
127
127
|
&& existingSession.status !== "stopped"
|
|
128
128
|
&& existingSession.status !== "broken";
|
|
129
|
-
|
|
129
|
+
// Quick Win (EXE-03): a resume Run that failed before durable Provider
|
|
130
|
+
// acceptance must not be retried against the same native Session. The
|
|
131
|
+
// Session is proven unusable for this delivery; the next launch must be
|
|
132
|
+
// a fresh Session after exact cleanup/reset. The guard only applies
|
|
133
|
+
// while the failed resume Run is the *latest* Run for the Role: once a
|
|
134
|
+
// newer Run exists (the fresh-Session launch), the historical failure
|
|
135
|
+
// is stale and must not permanently disable resume for the Role.
|
|
136
|
+
const latestRoleRun = store.listAgentRuns?.(task.id)
|
|
137
|
+
?.filter((run) => run.roleName === role.name)
|
|
138
|
+
.at(-1);
|
|
139
|
+
const failedResumeWithoutAcceptance = latestRoleRun !== undefined
|
|
140
|
+
&& latestRoleRun.mode === "resume"
|
|
141
|
+
&& latestRoleRun.status === "failed"
|
|
142
|
+
&& latestRoleRun.deliveredAt === undefined;
|
|
143
|
+
const mode = resumableSession && compatibleSession && !failedResumeWithoutAcceptance
|
|
144
|
+
? "resume"
|
|
145
|
+
: "new";
|
|
130
146
|
const runId = store.peekNextAgentRunId(task.id);
|
|
131
147
|
const wakeEnvelope = resolveLeaderWakeEnvelope(store, task.id);
|
|
132
148
|
const contextSnapshot = store.freezeLeaderContextSnapshot?.(task.id, role.name, now);
|