@zq-silk/yui 0.8.3 → 0.8.6
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/ARCHITECTURE.md +40 -22
- package/README.md +46 -16
- package/dist/cli/commandCatalog.js +23 -11
- package/dist/cli/operatorWizard.js +10 -20
- package/dist/cli.js +154 -16
- package/dist/commands/executionAuditCommands.js +30 -0
- package/dist/commands/operatorCommands.js +42 -1
- package/dist/commands/taskCommands.js +386 -138
- package/dist/commands/taskCompletionGate.js +36 -24
- package/dist/commands/taskContextCommand.js +6 -1
- package/dist/commands/taskInputCommands.js +48 -10
- package/dist/commands/taskNextActionCommand.js +36 -3
- package/dist/context/sessionBootstrapManifest.js +82 -1
- package/dist/controller/clientRuntime.js +7 -7
- package/dist/controller/controller.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +64 -5
- package/dist/controller/handoverCandidate.js +10 -3
- package/dist/controller/sessionNotify.js +4 -22
- package/dist/executor/agentAdapter.js +2 -2
- package/dist/executor/agentExecutor.js +25 -5
- package/dist/executor/fileRoleLaunchPlanner.js +16 -11
- package/dist/integration/gitIntegrationService.js +50 -2
- package/dist/integration/integrationCheckEvidenceReuse.js +53 -0
- package/dist/observability/executionAudit.js +47 -1
- package/dist/observability/faultClassification.js +6 -4
- package/dist/observability/orchestrationMetrics.js +196 -0
- package/dist/operator/operatorSessionHistory.js +36 -0
- package/dist/release/releaseHandover.js +7 -5
- package/dist/release/runtimeRelease.js +15 -0
- package/dist/repository/taskWorkspaceCoordinator.js +13 -10
- package/dist/review/deltaRecheck.js +3 -2
- package/dist/review/reviewFindingLedger.js +5 -4
- package/dist/review/reviewOutcomeClassifier.js +252 -54
- package/dist/review/taskFinalReviewContractEvent.js +1 -0
- package/dist/review/taskFinalReviewContractRebind.js +350 -0
- package/dist/run/runIdentity.js +10 -70
- package/dist/runtime/agentHost.js +3 -4
- package/dist/runtime/codexAppServerRuntime.js +6 -0
- package/dist/runtime/firstProgressStopLoss.js +52 -0
- package/dist/runtime/launchBroker.js +10 -2
- package/dist/runtime/runtimeDeadlines.js +14 -0
- package/dist/runtime/sessionTitle.js +24 -12
- package/dist/runtime/structuredProviderHost.js +7 -1
- package/dist/runtime/tmuxAdapters.js +10 -3
- package/dist/scheduler/activeRoleRunDelivery.js +20 -18
- package/dist/scheduler/leaderWakeupProcessor.js +33 -2
- package/dist/scheduler/wakeReason.js +1 -0
- package/dist/storage/sqliteStore.js +8 -1
- package/dist/storage/taskStore.js +10 -1
- package/dist/task/completionReadiness.js +48 -19
- package/dist/task/deliveryGuard.js +3 -1
- package/dist/task/nextAction.js +145 -52
- package/dist/task/repairWave.js +14 -1
- package/dist/task/task.js +10 -0
- package/dist/web/webSnapshot.js +7 -1
- package/i18n/README.zh-CN.md +28 -8
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +73 -31
- package/skills/yui-operator/SKILL.md +51 -10
- package/skills/yui-reviewer/SKILL.md +23 -0
|
@@ -2,13 +2,16 @@ import { usageError } from "../errors/cliError.js";
|
|
|
2
2
|
import { GitIntegrationService } from "../integration/gitIntegrationService.js";
|
|
3
3
|
import { createIntegrationAttempt } from "../integration/integrationAttempt.js";
|
|
4
4
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
5
|
+
import { taskDeliveryPath } from "../task/task.js";
|
|
5
6
|
import { publicationExternalKey } from "../task/publicationReference.js";
|
|
7
|
+
import { isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
|
|
6
8
|
import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
|
|
7
9
|
/**
|
|
8
10
|
* Verify the one supported ancestry waiver before `task complete` mutates any
|
|
9
11
|
* durable state. The explicit Publication must be the current verified merged
|
|
10
|
-
* record, bind the exact
|
|
11
|
-
* ancestry-divergent commit with the
|
|
12
|
+
* record, bind the exact physical Task head (and, for integrated delivery, its
|
|
13
|
+
* completed final Review), and name an ancestry-divergent commit with the
|
|
14
|
+
* exact same Git tree.
|
|
12
15
|
*/
|
|
13
16
|
export async function verifyTaskCompletionPublishedTree(taskId, publicationId, store, options = {}) {
|
|
14
17
|
const task = store.getTask(taskId);
|
|
@@ -27,10 +30,11 @@ export async function verifyTaskCompletionPublishedTree(taskId, publicationId, s
|
|
|
27
30
|
throw usageError(`Publication ${publication.id} must record exact local and remote commits.`);
|
|
28
31
|
}
|
|
29
32
|
const workspace = requireTaskWorkspace(store, task);
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|| latestReview
|
|
33
|
+
const integrated = taskDeliveryPath(task) === "integrated";
|
|
34
|
+
const latestReview = integrated ? latestTaskFinalReview(store, task.id) : undefined;
|
|
35
|
+
if (integrated && (latestReview === undefined
|
|
36
|
+
|| !isSemanticReviewRound(latestReview, store)
|
|
37
|
+
|| latestReview.taskCandidate === undefined)) {
|
|
34
38
|
throw usageError(`Task ${task.id} requires a latest completed Task-final Review before accepting a published tree.`);
|
|
35
39
|
}
|
|
36
40
|
const git = options.git ?? new NodeGitWorkspace();
|
|
@@ -40,18 +44,21 @@ export async function verifyTaskCompletionPublishedTree(taskId, publicationId, s
|
|
|
40
44
|
if (entry === undefined || entry.access !== "write") {
|
|
41
45
|
throw usageError(`Task ${task.id} has no writable managed main workspace for Project ${taskBinding.projectId}.`);
|
|
42
46
|
}
|
|
43
|
-
const reviewedCommit = latestReview.taskCandidate.projects.find(({ projectId }) => (projectId === taskBinding.projectId))?.commit;
|
|
44
|
-
if (reviewedCommit === undefined) {
|
|
45
|
-
throw usageError(`Task-final Review ${latestReview.id} omitted Project ${taskBinding.projectId}.`);
|
|
46
|
-
}
|
|
47
47
|
const actualCommit = (await git.inspect(entry.path, "HEAD")).baseCommit;
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
if (latestReview !== undefined) {
|
|
49
|
+
const reviewedCommit = latestReview.taskCandidate.projects.find(({ projectId }) => (projectId === taskBinding.projectId))?.commit;
|
|
50
|
+
if (reviewedCommit === undefined) {
|
|
51
|
+
throw usageError(`Task-final Review ${latestReview.id} omitted Project ${taskBinding.projectId}.`);
|
|
52
|
+
}
|
|
53
|
+
if (actualCommit !== reviewedCommit) {
|
|
54
|
+
throw usageError(`Task-final Review ${latestReview.id} does not match Task head `
|
|
55
|
+
+ `${taskBinding.projectId}@${actualCommit}.`);
|
|
56
|
+
}
|
|
51
57
|
}
|
|
52
58
|
actualHeads.set(taskBinding.projectId, actualCommit);
|
|
53
59
|
}
|
|
54
|
-
if (
|
|
60
|
+
if (latestReview !== undefined
|
|
61
|
+
&& actualHeads.size !== latestReview.taskCandidate.projects.length) {
|
|
55
62
|
throw usageError(`Task-final Review ${latestReview.id} Project set does not match Task ${task.id}.`);
|
|
56
63
|
}
|
|
57
64
|
const entry = workspaceProjectEntry(workspace, publication.projectId);
|
|
@@ -94,7 +101,7 @@ export async function verifyTaskCompletionPublishedTree(taskId, publicationId, s
|
|
|
94
101
|
taskId: task.id,
|
|
95
102
|
projectId: publication.projectId,
|
|
96
103
|
publicationId: publication.id,
|
|
97
|
-
reviewRoundId: latestReview.id,
|
|
104
|
+
...(latestReview === undefined ? {} : { reviewRoundId: latestReview.id }),
|
|
98
105
|
localCommit,
|
|
99
106
|
remoteCommit,
|
|
100
107
|
tree: localTree
|
|
@@ -115,13 +122,18 @@ export function assertTaskCompletionPublishedTreeProof(store, task, publicationI
|
|
|
115
122
|
|| publication.remoteCommit !== proof.remoteCommit) {
|
|
116
123
|
throw usageError(`Publication evidence changed before Task completion: ${publication.id}.`);
|
|
117
124
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
if (taskDeliveryPath(task) === "integrated") {
|
|
126
|
+
const latestReview = latestTaskFinalReview(store, task.id);
|
|
127
|
+
if (latestReview === undefined
|
|
128
|
+
|| latestReview.id !== proof.reviewRoundId
|
|
129
|
+
|| !isSemanticReviewRound(latestReview, store)
|
|
130
|
+
|| latestReview.taskCandidate === undefined
|
|
131
|
+
|| !sameTaskCandidate(latestReview.taskCandidate, actualCandidate)) {
|
|
132
|
+
throw usageError(`Task-final Review evidence changed before published-tree completion: ${task.id}.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (proof.reviewRoundId !== undefined) {
|
|
136
|
+
throw usageError(`Direct published-tree proof unexpectedly binds a final Review: ${task.id}.`);
|
|
125
137
|
}
|
|
126
138
|
const actualCommit = actualCandidate.projects.find(({ projectId }) => (projectId === proof.projectId))?.commit;
|
|
127
139
|
if (actualCommit !== proof.localCommit) {
|
|
@@ -321,7 +333,7 @@ async function hasCompletedTaskFinalReviewForCurrentCandidate(store, task, works
|
|
|
321
333
|
.filter((round) => (round.scope ?? "work-item") === "task")
|
|
322
334
|
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
|
|
323
335
|
const latest = rounds.at(-1);
|
|
324
|
-
if (latest === undefined || latest
|
|
336
|
+
if (latest === undefined || !isSemanticReviewRound(latest, store))
|
|
325
337
|
return false;
|
|
326
338
|
if (latest.taskCandidate === undefined)
|
|
327
339
|
return false;
|
|
@@ -354,7 +366,7 @@ function latestCommittedIntegration(store, taskId, projectId) {
|
|
|
354
366
|
function hasFrozenTaskBaseline(store, taskId, projectId, currentCommit) {
|
|
355
367
|
return store.listReviewRounds(taskId)
|
|
356
368
|
.some((round) => ((round.scope ?? "work-item") === "task"
|
|
357
|
-
&& round
|
|
369
|
+
&& isSemanticReviewRound(round, store)
|
|
358
370
|
&& round.taskCandidate !== undefined
|
|
359
371
|
&& round.taskCandidate.projects.some((entry) => (entry.projectId === projectId && entry.commit === currentCommit))));
|
|
360
372
|
}
|
|
@@ -4,6 +4,7 @@ import { formatTimestamp } from "../output/timePresentation.js";
|
|
|
4
4
|
import { isRoleRunStalled, latestStallProgressAt } from "../scheduler/roleRunStall.js";
|
|
5
5
|
import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
|
|
6
6
|
import { projectNextAction } from "../task/nextAction.js";
|
|
7
|
+
import { taskDeliveryPath } from "../task/task.js";
|
|
7
8
|
import { inspectTaskRoleSessionRecovery } from "./taskRoleRuntimeStatus.js";
|
|
8
9
|
import { summarizeExecutionGroup } from "../execution/executionGroup.js";
|
|
9
10
|
import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
|
|
@@ -51,6 +52,7 @@ export function runTaskContextCommand(args, store) {
|
|
|
51
52
|
}
|
|
52
53
|
return {
|
|
53
54
|
task,
|
|
55
|
+
deliveryPath: taskDeliveryPath(task),
|
|
54
56
|
execution,
|
|
55
57
|
reviewConfig: reader.getReviewConfig(),
|
|
56
58
|
brief: reader.getTaskBrief(task.id),
|
|
@@ -137,9 +139,12 @@ export function runTaskContextCommand(args, store) {
|
|
|
137
139
|
...(managedWorkspaces.length === 0
|
|
138
140
|
? [" None."]
|
|
139
141
|
: managedWorkspaces.map((workspace) => (` ${managedWorkspaceLabel(workspace)}: ${workspace.root} (${workspace.entries.filter(({ access }) => access === "write").length} writable / ${workspace.entries.length} Projects)`))),
|
|
142
|
+
`Delivery: ${taskDeliveryPath(task)}`,
|
|
140
143
|
`Completion evidence: ${task.requireIntegration
|
|
141
144
|
? "WorkItem, ChangeSet, and committed Integration required"
|
|
142
|
-
:
|
|
145
|
+
: task.projectBindings.length > 0
|
|
146
|
+
? "clean committed Task main required"
|
|
147
|
+
: "no Project evidence required"}`,
|
|
143
148
|
`Global review: ${reviewConfig === null
|
|
144
149
|
? "disabled"
|
|
145
150
|
: `${reviewConfig.roleName} (${reviewConfig.trigger})`}`,
|
|
@@ -8,6 +8,8 @@ import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
|
|
|
8
8
|
import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
9
9
|
import { clearMatchingLeaderStallAttention, isRoleRunStalled, RUN_RECOVERED_EVENT } from "../scheduler/roleRunStall.js";
|
|
10
10
|
import { terminalizeExactTaskRun } from "../lifecycle/exactRunTerminalization.js";
|
|
11
|
+
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
12
|
+
import { isLinuxProcessLive, listOwnedProcessTree } from "../runtime/sessionOwnerIdentity.js";
|
|
11
13
|
import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
|
|
12
14
|
const LEADER_ROLE = "leader";
|
|
13
15
|
export function runTaskInputCommand(args, store, options) {
|
|
@@ -270,16 +272,33 @@ export function isCurrentGlobalOperator(store, environment) {
|
|
|
270
272
|
const role = store.getGlobalRole("operator");
|
|
271
273
|
if (role === null)
|
|
272
274
|
return false;
|
|
273
|
-
const sessions = store.getGlobalRoleSessionSet(role.name);
|
|
274
|
-
const session = activeLiveRoleAgentSession(sessions);
|
|
275
|
-
if (sessions === null || session === null || sessions.activeAgentId !== role.activeAgentId) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
275
|
const agentId = exactIdentity(environment.YUI_AGENT_ID);
|
|
279
276
|
const adapterId = exactIdentity(environment.YUI_ADAPTER_ID);
|
|
280
277
|
const launchId = exactIdentity(environment.YUI_LAUNCH_ID);
|
|
281
278
|
const nativeSessionId = exactIdentity(environment.YUI_NATIVE_SESSION_ID);
|
|
282
279
|
const binding = role.agentBindings[role.activeAgentId];
|
|
280
|
+
if (agentId === undefined
|
|
281
|
+
|| adapterId === undefined
|
|
282
|
+
|| launchId === undefined
|
|
283
|
+
|| binding === undefined
|
|
284
|
+
|| binding.agentId !== agentId
|
|
285
|
+
|| binding.adapterId !== adapterId)
|
|
286
|
+
return false;
|
|
287
|
+
const sessions = store.getGlobalRoleSessionSet(role.name);
|
|
288
|
+
const session = activeLiveRoleAgentSession(sessions);
|
|
289
|
+
if (sessions === null || session === null || sessions.activeAgentId !== role.activeAgentId) {
|
|
290
|
+
// Codex learns its native Session ID only after the first Turn. During
|
|
291
|
+
// that narrow bootstrap window, authenticate against the durable launch
|
|
292
|
+
// reservation and its strongly attributed live process owner instead.
|
|
293
|
+
return nativeSessionId === undefined
|
|
294
|
+
&& binding.adapterId === "codex"
|
|
295
|
+
&& currentProcessBelongsToReservedGlobalLaunch(store, {
|
|
296
|
+
roleName: role.name,
|
|
297
|
+
agentId,
|
|
298
|
+
adapterId,
|
|
299
|
+
launchId
|
|
300
|
+
});
|
|
301
|
+
}
|
|
283
302
|
// A fresh Codex launch discovers its native Session asynchronously. Its
|
|
284
303
|
// launch envelope therefore cannot carry YUI_NATIVE_SESSION_ID, but the
|
|
285
304
|
// durable Session still binds that provider identity to the exact launch
|
|
@@ -292,11 +311,7 @@ export function isCurrentGlobalOperator(store, environment) {
|
|
|
292
311
|
&& session.adapterId === "codex"
|
|
293
312
|
&& session.launchId !== undefined
|
|
294
313
|
&& session.launchId === launchId));
|
|
295
|
-
return agentId
|
|
296
|
-
&& adapterId !== undefined
|
|
297
|
-
&& launchId !== undefined
|
|
298
|
-
&& binding !== undefined
|
|
299
|
-
&& binding.agentId === session.agentId
|
|
314
|
+
return binding.agentId === session.agentId
|
|
300
315
|
&& binding.adapterId === session.adapterId
|
|
301
316
|
&& session.agentId === agentId
|
|
302
317
|
&& session.adapterId === adapterId
|
|
@@ -304,6 +319,29 @@ export function isCurrentGlobalOperator(store, environment) {
|
|
|
304
319
|
&& session.launchId === launchId
|
|
305
320
|
&& nativeSessionMatches;
|
|
306
321
|
}
|
|
322
|
+
function currentProcessBelongsToReservedGlobalLaunch(store, input) {
|
|
323
|
+
if (store.getSessionOwner === undefined || store.getWorkMailbox === undefined)
|
|
324
|
+
return false;
|
|
325
|
+
const mailbox = store.getWorkMailbox(runtimeLifecycleTarget({
|
|
326
|
+
scope: "global",
|
|
327
|
+
roleName: input.roleName
|
|
328
|
+
}));
|
|
329
|
+
if (!isRuntimeLaunchReservation(mailbox?.processing, input.launchId)
|
|
330
|
+
|| hasRuntimeCleanupObligation(mailbox))
|
|
331
|
+
return false;
|
|
332
|
+
const owner = store.getSessionOwner(input.launchId);
|
|
333
|
+
if (owner === null
|
|
334
|
+
|| owner.owner.scope !== "global"
|
|
335
|
+
|| owner.owner.roleName !== input.roleName
|
|
336
|
+
|| owner.agentId !== input.agentId
|
|
337
|
+
|| owner.adapterId !== input.adapterId
|
|
338
|
+
|| owner.launchId !== input.launchId
|
|
339
|
+
|| owner.providerRoot.attribution !== "launch-env"
|
|
340
|
+
|| !isLinuxProcessLive(owner.providerRoot.pid, owner.providerRoot.startIdentity)) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return listOwnedProcessTree(owner.providerRoot.pid, owner.providerRoot.processGroupId).some(({ pid }) => pid === process.pid);
|
|
344
|
+
}
|
|
307
345
|
function inputAnswerer(environment) {
|
|
308
346
|
const env = environment ?? {};
|
|
309
347
|
if (env.YUI_SESSION_SCOPE === undefined && env.YUI_ROLE === undefined)
|
|
@@ -2,6 +2,8 @@ import { taskNotFound, usageError } from "../errors/cliError.js";
|
|
|
2
2
|
import { projectNextAction } from "../task/nextAction.js";
|
|
3
3
|
import { projectCompletionReadiness } from "../task/completionReadiness.js";
|
|
4
4
|
import { extractReviewFindings, planRepairWave } from "../task/repairWave.js";
|
|
5
|
+
import { taskDeliveryPath } from "../task/task.js";
|
|
6
|
+
import { projectTaskOrchestration } from "../observability/orchestrationMetrics.js";
|
|
5
7
|
/**
|
|
6
8
|
* Issue 07 (Leader convergence): read-only `yui task next-action <task>`.
|
|
7
9
|
* Folds the existing durable records into exactly one protocol-level next
|
|
@@ -55,7 +57,29 @@ export function runTaskNextActionCommand(args, store) {
|
|
|
55
57
|
throw taskNotFound(taskId);
|
|
56
58
|
completionReadiness = projectCompletionReadiness(readinessFacts);
|
|
57
59
|
}
|
|
58
|
-
|
|
60
|
+
const orchestration = projectTaskOrchestration({
|
|
61
|
+
task: reader.getTask(taskId),
|
|
62
|
+
runs: reader.listAgentRuns(taskId),
|
|
63
|
+
roleSessionSets: reader.listRoleSessionSets(taskId),
|
|
64
|
+
workItems: reader.listWorkItems(taskId),
|
|
65
|
+
changeSets: reader.listChangeSets(taskId),
|
|
66
|
+
reviewRounds: reader.listReviewRounds(taskId),
|
|
67
|
+
reviewFindings: reader.listReviewFindings(taskId),
|
|
68
|
+
integrations: reader.listIntegrationAttempts(taskId),
|
|
69
|
+
durableJobs: reader.listDurableJobs(taskId),
|
|
70
|
+
publications: reader.listPublicationReferences(taskId),
|
|
71
|
+
decisions: reader.listDecisions(taskId),
|
|
72
|
+
events: reader.listEvents(taskId),
|
|
73
|
+
managedWorkspaces: reader.listManagedWorkspaces(taskId)
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
deliveryPath: taskDeliveryPath(facts.task),
|
|
77
|
+
action,
|
|
78
|
+
repairWave,
|
|
79
|
+
completionReadiness,
|
|
80
|
+
knowledgeProposals,
|
|
81
|
+
orchestration
|
|
82
|
+
};
|
|
59
83
|
});
|
|
60
84
|
if (asJson) {
|
|
61
85
|
return {
|
|
@@ -66,7 +90,7 @@ export function runTaskNextActionCommand(args, store) {
|
|
|
66
90
|
}
|
|
67
91
|
return {
|
|
68
92
|
kind: "output",
|
|
69
|
-
output: renderNextAction(data.action, data.repairWave, data.completionReadiness, data.knowledgeProposals),
|
|
93
|
+
output: renderNextAction(data.action, data.deliveryPath, data.repairWave, data.completionReadiness, data.knowledgeProposals, data.orchestration.advisories),
|
|
70
94
|
data
|
|
71
95
|
};
|
|
72
96
|
}
|
|
@@ -84,9 +108,10 @@ function repairWaveFor(action, facts) {
|
|
|
84
108
|
return null;
|
|
85
109
|
return planRepairWave(round.id, findings);
|
|
86
110
|
}
|
|
87
|
-
function renderNextAction(action, repairWave, completionReadiness, knowledgeProposals) {
|
|
111
|
+
function renderNextAction(action, deliveryPath, repairWave, completionReadiness, knowledgeProposals, orchestrationAdvisories) {
|
|
88
112
|
const lines = [
|
|
89
113
|
`Task: ${action.taskId}`,
|
|
114
|
+
`Delivery: ${deliveryPath}`,
|
|
90
115
|
`Next action: ${action.kind}`,
|
|
91
116
|
`Reason: ${action.reason}`,
|
|
92
117
|
...(action.refs.length === 0
|
|
@@ -131,6 +156,10 @@ function renderNextAction(action, repairWave, completionReadiness, knowledgeProp
|
|
|
131
156
|
lines.push(`Completion readiness: ${completionReadiness.blockers.length} blocker(s)`, ...completionReadiness.blockers.map((blocker) => ` ${blocker.code} (${blocker.ref.kind} ${blocker.ref.id}): ${blocker.reason}`
|
|
132
157
|
+ ` — fix: ${blocker.fix}`));
|
|
133
158
|
}
|
|
159
|
+
if (completionReadiness.advisories.length > 0) {
|
|
160
|
+
lines.push(`Completion advisories (non-blocking): ${completionReadiness.advisories.length}`, ...completionReadiness.advisories.map((advisory) => ` ${advisory.code} (${advisory.ref.kind} ${advisory.ref.id}): ${advisory.reason}`
|
|
161
|
+
+ ` — fix before archive: ${advisory.fix}`));
|
|
162
|
+
}
|
|
134
163
|
}
|
|
135
164
|
if (repairWave !== null) {
|
|
136
165
|
lines.push(`Repair wave (${repairWave.openFindingCount} open finding(s), ${repairWave.groups.length} group(s)):`, ...repairWave.groups.map((group) => ` ${group.id}: findings ${group.findingIds.join(", ")}`
|
|
@@ -141,5 +170,9 @@ function renderNextAction(action, repairWave, completionReadiness, knowledgeProp
|
|
|
141
170
|
lines.push(`Knowledge proposals (non-blocking): ${knowledgeProposals.length} pending`, ...knowledgeProposals.map((proposal) => ` ${proposal.projectId}/${proposal.proposalId}: ${proposal.title}`
|
|
142
171
|
+ ` — review: yui project knowledge proposals list ${proposal.projectId}`));
|
|
143
172
|
}
|
|
173
|
+
if (orchestrationAdvisories.length > 0) {
|
|
174
|
+
lines.push(`Orchestration advisories (non-blocking): ${orchestrationAdvisories.length}`, ...orchestrationAdvisories.map((advisory) => ` ${advisory.code}: ${advisory.reason}`
|
|
175
|
+
+ (advisory.refs.length === 0 ? "" : ` — refs ${advisory.refs.join(", ")}`)));
|
|
176
|
+
}
|
|
144
177
|
return `${lines.join("\n")}\n`;
|
|
145
178
|
}
|
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { chmodSync } from "node:fs";
|
|
2
|
+
import { chmodSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { exactControlPlaneCommandPrefix, exactControlPlaneDigest, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
|
|
5
5
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
6
6
|
import { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
|
|
7
7
|
export { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
|
|
8
|
+
/** Read back one immutable Session Manifest and verify its content digest. */
|
|
9
|
+
export function readSessionBootstrapManifest(path) {
|
|
10
|
+
const source = resolve(path);
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(readFileSync(source, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new Error(`Session Manifest is unreadable: ${source}.`, { cause: error });
|
|
17
|
+
}
|
|
18
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
19
|
+
throw new Error("Session Manifest is invalid.");
|
|
20
|
+
}
|
|
21
|
+
const record = parsed;
|
|
22
|
+
const claimedDigest = requireDigest(record.digest, "Session Manifest digest");
|
|
23
|
+
const { digest: _digest, ...body } = record;
|
|
24
|
+
if (digest(body) !== claimedDigest) {
|
|
25
|
+
throw new Error("Session Manifest digest does not match its immutable content.");
|
|
26
|
+
}
|
|
27
|
+
if (record.schemaVersion !== SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION
|
|
28
|
+
|| record.protocol !== SESSION_CONTEXT_PROTOCOL
|
|
29
|
+
|| record.owner === null
|
|
30
|
+
|| typeof record.owner !== "object"
|
|
31
|
+
|| (record.owner.scope !== "global"
|
|
32
|
+
&& record.owner.scope !== "task")
|
|
33
|
+
|| typeof record.effectiveRevision !== "number"
|
|
34
|
+
|| !Number.isSafeInteger(record.effectiveRevision)
|
|
35
|
+
|| record.effectiveRevision < 1
|
|
36
|
+
|| (record.roleKind !== "operator"
|
|
37
|
+
&& record.roleKind !== "global"
|
|
38
|
+
&& record.roleKind !== "leader"
|
|
39
|
+
&& record.roleKind !== "worker"
|
|
40
|
+
&& record.roleKind !== "reviewer")
|
|
41
|
+
|| record.controlPlane === null
|
|
42
|
+
|| typeof record.controlPlane !== "object"
|
|
43
|
+
|| !Array.isArray(record.skills)
|
|
44
|
+
|| record.roleProfileRef === null
|
|
45
|
+
|| typeof record.roleProfileRef !== "object"
|
|
46
|
+
|| record.contextProtocol === null
|
|
47
|
+
|| typeof record.contextProtocol !== "object") {
|
|
48
|
+
throw new Error("Session Manifest shape is invalid.");
|
|
49
|
+
}
|
|
50
|
+
const owner = record.owner;
|
|
51
|
+
if (owner.scope === "task" && typeof owner.taskId !== "string") {
|
|
52
|
+
throw new Error("Task Session Manifest owner is invalid.");
|
|
53
|
+
}
|
|
54
|
+
const control = record.controlPlane;
|
|
55
|
+
requireText(control.descriptorPath, "Session Manifest control descriptor path");
|
|
56
|
+
requireText(control.sessionCliPath, "Session Manifest CLI path");
|
|
57
|
+
requireDigest(control.digest, "Session Manifest control-plane digest");
|
|
58
|
+
for (const skill of record.skills) {
|
|
59
|
+
if (skill === null || typeof skill !== "object") {
|
|
60
|
+
throw new Error("Session Manifest Skill entry is invalid.");
|
|
61
|
+
}
|
|
62
|
+
const entry = skill;
|
|
63
|
+
requireText(entry.id, "Session Manifest Skill id");
|
|
64
|
+
requireText(entry.path, "Session Manifest Skill path");
|
|
65
|
+
requireDigest(entry.digest, "Session Manifest Skill digest");
|
|
66
|
+
}
|
|
67
|
+
const profile = record.roleProfileRef;
|
|
68
|
+
requireDigest(profile.digest, "Session Manifest Role Profile digest");
|
|
69
|
+
requireText(profile.path, "Session Manifest Role Profile path");
|
|
70
|
+
const protocol = record.contextProtocol;
|
|
71
|
+
requireText(protocol.loadCommand, "Session Manifest Context load command");
|
|
72
|
+
if (protocol.expandCommand !== undefined) {
|
|
73
|
+
requireText(protocol.expandCommand, "Session Manifest Context expand command");
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze(parsed);
|
|
76
|
+
}
|
|
8
77
|
export function materializeSessionBootstrap(input) {
|
|
9
78
|
const home = resolve(input.yuiHome);
|
|
10
79
|
const controlDigest = exactControlPlaneDigest(input.controlPlane);
|
|
@@ -79,3 +148,15 @@ function digest(value) {
|
|
|
79
148
|
const bytes = typeof value === "string" ? value : JSON.stringify(value);
|
|
80
149
|
return createHash("sha256").update(bytes).digest("hex");
|
|
81
150
|
}
|
|
151
|
+
function requireDigest(value, label) {
|
|
152
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
|
|
153
|
+
throw new Error(`${label} is invalid.`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function requireText(value, label) {
|
|
158
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
|
|
159
|
+
throw new Error(`${label} is invalid.`);
|
|
160
|
+
}
|
|
161
|
+
return value.trim();
|
|
162
|
+
}
|
|
@@ -10,12 +10,9 @@ import { EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES } from "./domainIdentity.js";
|
|
|
10
10
|
import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
|
|
11
11
|
import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
|
|
12
12
|
import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
|
|
13
|
+
import { CONTROLLER_SHUTDOWN_TIMEOUT_MS, LIFECYCLE_REQUEST_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
|
|
13
14
|
const STARTUP_TIMEOUT_MS = 5_000;
|
|
14
|
-
// A lifecycle RPC may legitimately occupy the Controller for 30 seconds.
|
|
15
|
-
// Restart must allow that request to drain before deciding shutdown is stuck.
|
|
16
|
-
const SHUTDOWN_TIMEOUT_MS = 45_000;
|
|
17
15
|
const POLL_INTERVAL_MS = 50;
|
|
18
|
-
const LIFECYCLE_REQUEST_TIMEOUT_MS = 30_000;
|
|
19
16
|
const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
|
|
20
17
|
const CONFIGURATION_REFRESH_TIMEOUT_MS = 500;
|
|
21
18
|
const CONTROLLER_OPERATIONAL_ENVIRONMENT = [
|
|
@@ -168,7 +165,7 @@ function spawnDetachedFileTaskController(home, environment) {
|
|
|
168
165
|
/** Stops the per-home Controller and waits until its owned discovery is gone. */
|
|
169
166
|
export async function stopFileTaskController(home, options = {}) {
|
|
170
167
|
const call = options.call ?? callController;
|
|
171
|
-
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs,
|
|
168
|
+
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, CONTROLLER_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
|
|
172
169
|
const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
|
|
173
170
|
const expectedPid = options.expectedPid === undefined
|
|
174
171
|
? undefined
|
|
@@ -224,7 +221,7 @@ export async function stopFileTaskController(home, options = {}) {
|
|
|
224
221
|
/** Restarts only the per-home Controller process; managed tmux sessions remain untouched. */
|
|
225
222
|
export async function restartFileTaskController(home, options = {}) {
|
|
226
223
|
const call = options.call ?? callController;
|
|
227
|
-
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs,
|
|
224
|
+
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, CONTROLLER_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
|
|
228
225
|
const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
|
|
229
226
|
let current = null;
|
|
230
227
|
let previousPid;
|
|
@@ -529,7 +526,10 @@ export class FileTaskWorkflowRuntime {
|
|
|
529
526
|
&& this.workspacePreparer !== undefined) {
|
|
530
527
|
await this.workspacePreparer.prepareTaskWorkspace(taskId);
|
|
531
528
|
}
|
|
532
|
-
await callFileTaskController(this.home, "scheduler.scan", {},
|
|
529
|
+
await callFileTaskController(this.home, "scheduler.scan", {}, {
|
|
530
|
+
...this.clientOptions,
|
|
531
|
+
requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
|
|
532
|
+
});
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
535
|
const MANAGED_RUNTIME_ENVIRONMENT = new Set(YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES);
|
|
@@ -10,6 +10,7 @@ import { processOperatorInputNotifications } from "../scheduler/operatorInputNot
|
|
|
10
10
|
import { startControllerServer } from "../core/controllerServer.js";
|
|
11
11
|
import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
|
|
12
12
|
import { isProjectMaintenanceFenced } from "../repository/projectMaintenanceLock.js";
|
|
13
|
+
import { isHandoverLockHeld } from "../release/runtimeRelease.js";
|
|
13
14
|
import { KeyedWorkQueue } from "../coordination/keyedWorkQueue.js";
|
|
14
15
|
import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
|
|
15
16
|
import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
|
|
@@ -45,7 +46,7 @@ const ZERO_DRAIN_METRICS = Object.freeze({
|
|
|
45
46
|
* Runs one lean scheduler pass. Due native Turn completions are folded before
|
|
46
47
|
* liveness, so a valid Hook boundary fences destructive process reconciliation.
|
|
47
48
|
*/
|
|
48
|
-
export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer, blockedTaskIds = new Set(), inputDeliveryRecoveryCutoff, diagnosticAfterMs = DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS) {
|
|
49
|
+
export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer, blockedTaskIds = new Set(), inputDeliveryRecoveryCutoff, diagnosticAfterMs = DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS, leaderWakeFence) {
|
|
49
50
|
const compiledSelection = compileReconcileSelection(scope);
|
|
50
51
|
const selection = includeOperator
|
|
51
52
|
? { ...compiledSelection, blockedTaskIds }
|
|
@@ -58,7 +59,9 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
|
|
|
58
59
|
await controlEventLoopTurn();
|
|
59
60
|
const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes, blockedTaskIds);
|
|
60
61
|
const roleSelection = selectionWithoutFailedCleanupRoles(store, selection, failedCleanupRoles);
|
|
61
|
-
const
|
|
62
|
+
const availableWakeupSelection = () => (leaderWakeFence?.() === true
|
|
63
|
+
? exactTaskSelection(new Set())
|
|
64
|
+
: selectionWithoutFailedLeaderCleanupTasks(store, selection, failedCleanupRoles));
|
|
62
65
|
if (selection.full)
|
|
63
66
|
repairOrphanedActiveTasks(store, now, selection);
|
|
64
67
|
const claimedTaskMailboxes = claimSelectedTaskMailboxes(store, selection, now);
|
|
@@ -66,7 +69,7 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
|
|
|
66
69
|
// Durable Leader work that already has a ready Task workspace belongs to
|
|
67
70
|
// the control path. Dispatch it before any unrelated Task workspace I/O;
|
|
68
71
|
// the processor itself retains the fail-closed workspace-ready guard.
|
|
69
|
-
const initialWakeups = selectedPendingWakeups(store,
|
|
72
|
+
const initialWakeups = selectedPendingWakeups(store, availableWakeupSelection());
|
|
70
73
|
const initialWakeupResults = await processLeaderWakeups(store, delivery, now, exactTaskSelection(new Set(initialWakeups.keys())));
|
|
71
74
|
// Preserve ready-Leader-first ordering, then bound the repeated state
|
|
72
75
|
// projections that follow it in this pass.
|
|
@@ -134,7 +137,8 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
|
|
|
134
137
|
&& workspacePreparation.ready.has(result.taskId)
|
|
135
138
|
? [result.taskId]
|
|
136
139
|
: [])));
|
|
137
|
-
const laterWakeupTaskIds = new Set([...selectedPendingWakeups(store,
|
|
140
|
+
const laterWakeupTaskIds = new Set([...selectedPendingWakeups(store, availableWakeupSelection())]
|
|
141
|
+
.flatMap(([taskId, wakeup]) => {
|
|
138
142
|
const initial = initialWakeups.get(taskId);
|
|
139
143
|
return initial === undefined
|
|
140
144
|
|| !pendingWakeupsMatch(initial, wakeup)
|
|
@@ -823,6 +827,7 @@ export class FileTaskController {
|
|
|
823
827
|
#onExpiredEphemeralDomain;
|
|
824
828
|
#maintenanceFence;
|
|
825
829
|
#onMaintenanceFenceDefer;
|
|
830
|
+
#leaderWakeFence;
|
|
826
831
|
#jobSupervisor;
|
|
827
832
|
#continuationReconciler;
|
|
828
833
|
#current;
|
|
@@ -867,6 +872,7 @@ export class FileTaskController {
|
|
|
867
872
|
this.#onExpiredEphemeralDomain = options.onExpiredEphemeralDomain;
|
|
868
873
|
this.#maintenanceFence = options.maintenanceFence;
|
|
869
874
|
this.#onMaintenanceFenceDefer = options.onMaintenanceFenceDefer;
|
|
875
|
+
this.#leaderWakeFence = options.leaderWakeFence;
|
|
870
876
|
this.#jobSupervisor = options.jobSupervisor;
|
|
871
877
|
this.#continuationReconciler = options.continuationReconciler;
|
|
872
878
|
this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
|
|
@@ -1118,7 +1124,7 @@ export class FileTaskController {
|
|
|
1118
1124
|
// processes Leader wakeups.
|
|
1119
1125
|
this.#jobSupervisor?.reconcile(this.#now());
|
|
1120
1126
|
if (scope.kind === "full") {
|
|
1121
|
-
result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds, this.#startedAt, this.#diagnosticAfterMs);
|
|
1127
|
+
result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds, this.#startedAt, this.#diagnosticAfterMs, this.#leaderWakeFence);
|
|
1122
1128
|
}
|
|
1123
1129
|
else {
|
|
1124
1130
|
const dirtyPass = await this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds);
|
|
@@ -1216,7 +1222,7 @@ export class FileTaskController {
|
|
|
1216
1222
|
const taskScopes = partition.taskScopes.filter((taskScope) => (!blockedTaskIds.has(taskScope.taskId)));
|
|
1217
1223
|
const orderedResults = [];
|
|
1218
1224
|
if (partition.globalKeys.length > 0) {
|
|
1219
|
-
orderedResults.push(await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: partition.globalKeys }, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#startedAt, this.#diagnosticAfterMs));
|
|
1225
|
+
orderedResults.push(await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: partition.globalKeys }, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#startedAt, this.#diagnosticAfterMs, this.#leaderWakeFence));
|
|
1220
1226
|
}
|
|
1221
1227
|
if (taskScopes.length === 0
|
|
1222
1228
|
|| this.#stopped
|
|
@@ -1249,7 +1255,7 @@ export class FileTaskController {
|
|
|
1249
1255
|
if (this.#stopped || this.#pendingFull)
|
|
1250
1256
|
continue;
|
|
1251
1257
|
try {
|
|
1252
|
-
taskResults[selected.index] = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: selected.taskScope.keys }, false, taskCleanupOutcomes[selected.index], this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#startedAt, this.#diagnosticAfterMs);
|
|
1258
|
+
taskResults[selected.index] = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: selected.taskScope.keys }, false, taskCleanupOutcomes[selected.index], this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#startedAt, this.#diagnosticAfterMs, this.#leaderWakeFence);
|
|
1253
1259
|
this.#clearTaskPassRetry(selected.taskScope.taskId);
|
|
1254
1260
|
}
|
|
1255
1261
|
catch (error) {
|
|
@@ -1676,7 +1682,9 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1676
1682
|
const runtime = new FileTaskController(store, delivery, {
|
|
1677
1683
|
...options,
|
|
1678
1684
|
maintenanceFence: options.maintenanceFence
|
|
1679
|
-
?? ((projectId) => isProjectMaintenanceFenced(home, projectId))
|
|
1685
|
+
?? ((projectId) => isProjectMaintenanceFenced(home, projectId)),
|
|
1686
|
+
leaderWakeFence: options.leaderWakeFence
|
|
1687
|
+
?? (() => isHandoverLockHeld(home))
|
|
1680
1688
|
});
|
|
1681
1689
|
let stopping = false;
|
|
1682
1690
|
const lifecycleRequests = new Set();
|