@zq-silk/yui 0.6.4 → 0.6.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/README.md +11 -3
- package/dist/cli/updateOrchestrator.js +12 -6
- package/dist/cli.js +12 -0
- package/dist/controller/agentRuntimeObserver.js +76 -16
- package/dist/controller/controller.js +398 -76
- package/dist/controller/fileSchedulerStoreAdapter.js +60 -51
- package/dist/controller/jobSupervisor.js +2 -16
- package/dist/controller/resourceInventoryLinux.js +73 -20
- package/dist/controller/runtime.js +1 -0
- package/dist/controller/runtimeEventProcessor.js +171 -31
- package/dist/coordination/keyedWorkQueue.js +189 -0
- package/dist/runtime/runtimeSessionCandidate.js +43 -0
- package/dist/scheduler/activeRoleRunDelivery.js +2 -4
- package/dist/scheduler/activeTaskProgress.js +2 -4
- package/dist/scheduler/leaderWakeupProcessor.js +3 -1
- package/dist/scheduler/ports.js +35 -2
- package/dist/scheduler/roleRunLiveness.js +44 -22
- package/dist/scheduler/roleRunStall.js +49 -25
- package/dist/storage/sqliteSchema.js +363 -14
- package/dist/storage/sqliteStore.js +403 -30
- package/dist/storage/storeRpc.js +5 -0
- package/dist/storage/taskStore.js +104 -6
- package/i18n/README.zh-CN.md +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ yui setup
|
|
|
21
21
|
yui doctor
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
`setup` is interactive. It detects installed Agent CLIs, asks which Agents to configure, selects the default and Operator Agent, and probes each selected CLI for its current models. It configures the Leader and Operator, then explains that the global Worker configuration is copied into new Task Roles and asks whether Worker should reuse Leader or be configured separately. Model selection is followed by that model's supported reasoning efforts. Setup also confirms the Project workspace outside Yui home and offers shell-completion setup. The picker includes the native CLI default and a custom-value option. Running setup again preserves existing Tasks, Roles, and the installation's Project workspace while allowing safe configuration changes.
|
|
24
|
+
`setup` is interactive. It detects installed Agent CLIs, asks which Agents to configure, selects the default and Operator Agent, and probes each selected CLI for its current models. It configures the Leader and Operator, then explains that the global Worker configuration is copied into new Task Roles and asks whether Worker should reuse Leader or be configured separately. Model selection is followed by that model's supported reasoning efforts. Setup also confirms the Project workspace outside Yui home and offers shell-completion setup. The picker includes the native CLI default and a custom-value option. Running setup again preserves existing Tasks, Roles, and the installation's Project workspace while allowing safe configuration changes. A successful setup ensures the current Home's detached Controller is running before it returns.
|
|
25
25
|
|
|
26
26
|
Model and effort are per-Agent Role settings, so Operator, Leader, and the global Worker can use different values even when they share an Agent CLI. Interactive Role flows validate those settings against the selected Agent runtime. Worker Profile model and effort fields are provider-neutral child-execution hints and therefore remain explicit, scriptable values rather than Agent capability selections.
|
|
27
27
|
|
|
@@ -737,6 +737,12 @@ hiding the resources that remain. Use `--all` to include discovered Yui homes.
|
|
|
737
737
|
|
|
738
738
|
`controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It does not stop or restart managed tmux/Agent sessions.
|
|
739
739
|
|
|
740
|
+
Successful `setup`, `upgrade`, and `update` commands ensure that the current
|
|
741
|
+
Home has a running Controller, starting one when the Home was previously idle.
|
|
742
|
+
Read-only commands and `upgrade --dry-run` do not start a Controller. `update`
|
|
743
|
+
also replaces an already-running Controller only after the new binary passes its
|
|
744
|
+
health checks.
|
|
745
|
+
|
|
740
746
|
Its recovery reconciliation runs every 120 seconds by default. Normal durable state changes enqueue a Task, Role, or Operator key and return immediately; keys received in the same fixed 100 ms window trigger one non-overlapping targeted pass. Operator presentation has an independent lane, so a blocked Task workspace operation cannot delay a user question. Periodic Git/worktree work is limited to Tasks with durable Task-mailbox work, while active Role liveness uses one tmux inventory. Agent Driver Hooks write exact-fenced observations to the durable runtime inbox without starting or waiting for the Controller. A terminal Turn observation gives a legal yield/input/completion two seconds to win before a forgotten Run fails its workflow contract. Durable mailboxes freeze the current batch while new signals merge into the next batch. Task-orchestration failures retain the exact Controller-owned processing batch for two bounded fast retries and later periodic recovery; a successful retry completes that batch before newer pending work is claimed. Recommended InputRequest and pending Turn deadlines share one nearest-deadline selector and therefore do not wait for the recovery interval. Explicit `task reconcile` still requests an immediate recovery pass. The retained loop is:
|
|
741
747
|
|
|
742
748
|
1. dispatch pending Leader wakes whose Task workspaces are already ready;
|
|
@@ -821,8 +827,10 @@ preflight does not create or validate a staged Home and is not
|
|
|
821
827
|
stops the exact old Controller PID. Current and compatible-old Homes use the
|
|
822
828
|
no-Home-mutation fast path; migration-required Homes first require a clear
|
|
823
829
|
offline Run/Session/lifecycle inventory, then use the timestamped-backup switch.
|
|
824
|
-
Both paths
|
|
825
|
-
|
|
830
|
+
Both paths run a new-binary health check before the authenticated Controller
|
|
831
|
+
handoff. When an old Controller exists, it is stopped with an exact PID fence;
|
|
832
|
+
when no old Controller existed, the same verified new Controller is started
|
|
833
|
+
before the update reports success.
|
|
826
834
|
Yui promotes the **same artifact it staged**
|
|
827
835
|
(binary activation pins the exact staged version, never a second bare `@latest`);
|
|
828
836
|
the staged version must be a **concrete semver** — a `latest`/dist-tag sentinel,
|
|
@@ -223,12 +223,15 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
|
|
|
223
223
|
};
|
|
224
224
|
return restoreBeforeSwitchOrReport(ports, home, lifecycle, storageBackupPath, failure);
|
|
225
225
|
}
|
|
226
|
-
if (lifecycle?.
|
|
226
|
+
if (lifecycle?.ensureRunning === true) {
|
|
227
227
|
try {
|
|
228
228
|
ports.startController(home);
|
|
229
229
|
}
|
|
230
230
|
catch (error) {
|
|
231
231
|
const unknownActive = isUnknownActiveControllerFailure(error);
|
|
232
|
+
const startFailureAction = lifecycle.wasRunning
|
|
233
|
+
? "The Home was not migrated. Keep writes quiesced and restore the previously running Controller identity before retrying."
|
|
234
|
+
: "The Home was not migrated. Keep writes quiesced and start the replacement Controller after verifying the activated binary.";
|
|
232
235
|
const failure = {
|
|
233
236
|
outcome: "aborted",
|
|
234
237
|
phase: "post-verify",
|
|
@@ -237,7 +240,7 @@ function activateAndVerify(ports, staged, home, storageBackupPath, lifecycle, pa
|
|
|
237
240
|
action: unknownActive
|
|
238
241
|
? unknownActiveControllerAction(home, storageBackupPath)
|
|
239
242
|
: storageBackupPath === undefined
|
|
240
|
-
?
|
|
243
|
+
? startFailureAction
|
|
241
244
|
: postSwitchRecoveryAction(home, storageBackupPath),
|
|
242
245
|
recoverable: false,
|
|
243
246
|
version: staged.version,
|
|
@@ -276,8 +279,9 @@ function captureControllerLifecycle(ports, version, home) {
|
|
|
276
279
|
ports.startController,
|
|
277
280
|
ports.restoreController
|
|
278
281
|
].some((port) => port !== undefined);
|
|
279
|
-
if (!supplied)
|
|
280
|
-
return { lifecycle: { wasRunning: false, stopped: false } };
|
|
282
|
+
if (!supplied) {
|
|
283
|
+
return { lifecycle: { ensureRunning: false, wasRunning: false, stopped: false } };
|
|
284
|
+
}
|
|
281
285
|
if (ports.controllerStatus === undefined
|
|
282
286
|
|| ports.stopController === undefined
|
|
283
287
|
|| ports.startController === undefined
|
|
@@ -315,8 +319,9 @@ function captureControllerLifecycle(ports, version, home) {
|
|
|
315
319
|
version
|
|
316
320
|
};
|
|
317
321
|
}
|
|
318
|
-
if (!status.running)
|
|
319
|
-
return { lifecycle: { wasRunning: false, stopped: false } };
|
|
322
|
+
if (!status.running) {
|
|
323
|
+
return { lifecycle: { ensureRunning: true, wasRunning: false, stopped: false } };
|
|
324
|
+
}
|
|
320
325
|
if (!isPositivePid(status.pid)) {
|
|
321
326
|
return {
|
|
322
327
|
outcome: "aborted",
|
|
@@ -365,6 +370,7 @@ function captureControllerLifecycle(ports, version, home) {
|
|
|
365
370
|
}
|
|
366
371
|
return {
|
|
367
372
|
lifecycle: {
|
|
373
|
+
ensureRunning: true,
|
|
368
374
|
wasRunning: true,
|
|
369
375
|
stopped: true,
|
|
370
376
|
identity: status.identity
|
package/dist/cli.js
CHANGED
|
@@ -177,6 +177,10 @@ export async function main() {
|
|
|
177
177
|
};
|
|
178
178
|
validateSetupInvocation(args.slice(1), setupIo);
|
|
179
179
|
const output = await runSetupCommand(args.slice(1), process.env, new NodeCommandExecutor(), setupIo);
|
|
180
|
+
// A successful setup leaves the Home ready for normal Yui work. Start the
|
|
181
|
+
// detached per-Home Controller even when setup began with no Controller;
|
|
182
|
+
// read-only commands and failed setup still remain non-starting paths.
|
|
183
|
+
await ensureFileTaskController(home, { environment: process.env });
|
|
180
184
|
const refresh = await refreshRunningFileTaskControllerEnvironment(home, openCompatibleFileTaskStore(home), process.env);
|
|
181
185
|
emit(withControllerRefreshWarning(output, refresh, "Agent environment"));
|
|
182
186
|
return;
|
|
@@ -220,6 +224,14 @@ export async function main() {
|
|
|
220
224
|
const result = await runUpgradeCommand(args.slice(1), home, process.env.YUI_UPDATE_EXTERNALLY_QUIESCED === "1"
|
|
221
225
|
? { controllerLifecycle: "externally-quiesced" }
|
|
222
226
|
: {});
|
|
227
|
+
// Public execute upgrades leave the Home operational even when no
|
|
228
|
+
// Controller existed before the command. Dry-run and the staged updater's
|
|
229
|
+
// externally-quiesced preflight must remain read-only/lifecycle-neutral.
|
|
230
|
+
if (args.length === 1
|
|
231
|
+
&& result.exitCode === 0
|
|
232
|
+
&& process.env.YUI_UPDATE_EXTERNALLY_QUIESCED !== "1") {
|
|
233
|
+
await ensureFileTaskController(home, { environment: process.env });
|
|
234
|
+
}
|
|
223
235
|
process.exitCode = result.exitCode;
|
|
224
236
|
emit(result.output, false, result.data);
|
|
225
237
|
return;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
3
3
|
import { createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
|
|
4
|
+
const DEFAULT_MAX_CONCURRENT_SAMPLES = 8;
|
|
5
|
+
const MAX_CONCURRENT_SAMPLES = 64;
|
|
4
6
|
/**
|
|
5
7
|
* Controller-owned, provider-independent sampler. Drivers own source parsing;
|
|
6
8
|
* this component owns active-Run discovery, cursor lifetime, canonical event
|
|
@@ -11,11 +13,13 @@ export class AgentRuntimeObserver {
|
|
|
11
13
|
inbox;
|
|
12
14
|
drivers;
|
|
13
15
|
#states = new Map();
|
|
16
|
+
#maxConcurrentSamples;
|
|
14
17
|
#sequence = 0;
|
|
15
|
-
constructor(store, inbox, drivers = builtinAgentDriverRegistry()) {
|
|
18
|
+
constructor(store, inbox, drivers = builtinAgentDriverRegistry(), options = {}) {
|
|
16
19
|
this.store = store;
|
|
17
20
|
this.inbox = inbox;
|
|
18
21
|
this.drivers = drivers;
|
|
22
|
+
this.#maxConcurrentSamples = sampleConcurrency(options.maxConcurrentSamples);
|
|
19
23
|
}
|
|
20
24
|
async sample(now = new Date()) {
|
|
21
25
|
const active = this.activeSources();
|
|
@@ -25,7 +29,9 @@ export class AgentRuntimeObserver {
|
|
|
25
29
|
this.#states.delete(key);
|
|
26
30
|
}
|
|
27
31
|
const dirty = new Set();
|
|
28
|
-
|
|
32
|
+
const sequenceBase = this.#sequence;
|
|
33
|
+
this.#sequence += active.length;
|
|
34
|
+
await forEachConcurrent(active, this.#maxConcurrentSamples, async ({ key, fence, source, freshSession, persistedState }, index) => {
|
|
29
35
|
const existingState = this.#states.get(key);
|
|
30
36
|
// Cursor state is intentionally process-local, but the latest canonical
|
|
31
37
|
// usage/activity baseline is durable. Rehydrate it after Controller
|
|
@@ -49,7 +55,10 @@ export class AgentRuntimeObserver {
|
|
|
49
55
|
}
|
|
50
56
|
state.cursor = sample.cursor;
|
|
51
57
|
const at = now.toISOString();
|
|
52
|
-
|
|
58
|
+
// Reserve sequence numbers in source-key order before sampling. Provider
|
|
59
|
+
// latency can change completion order without changing observation
|
|
60
|
+
// identity or the canonical sequence assigned to a source.
|
|
61
|
+
const sequence = sequenceBase + index;
|
|
53
62
|
if (existingState === undefined && freshSession && state.usage === undefined) {
|
|
54
63
|
const zero = Object.freeze({ inputTokens: 0, outputTokens: 0 });
|
|
55
64
|
this.inbox.enqueueObservation(createRuntimeObservation({
|
|
@@ -114,28 +123,52 @@ export class AgentRuntimeObserver {
|
|
|
114
123
|
if (sample.activityId !== undefined)
|
|
115
124
|
state.activityId = sample.activityId;
|
|
116
125
|
this.#states.set(key, state);
|
|
117
|
-
})
|
|
118
|
-
return Object.freeze([...dirty].sort());
|
|
126
|
+
});
|
|
127
|
+
return Object.freeze([...dirty].sort(numericCompare));
|
|
119
128
|
}
|
|
120
129
|
activeSources() {
|
|
121
130
|
const result = [];
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
const indexedTaskIds = this.store.listActiveTaskIds?.();
|
|
132
|
+
// FileTaskStore remains a development/compatibility fallback. The normal
|
|
133
|
+
// Controller store is SQLite and must discover only its indexed hot set.
|
|
134
|
+
const activeTasks = indexedTaskIds === undefined
|
|
135
|
+
? this.store.listTasks().filter((task) => task.status === "active")
|
|
136
|
+
: [...new Set(indexedTaskIds)]
|
|
137
|
+
.sort(numericCompare)
|
|
138
|
+
.map((taskId) => this.store.getTask(taskId))
|
|
139
|
+
.filter((task) => (task !== null && task.status === "active"));
|
|
140
|
+
for (const task of activeTasks) {
|
|
141
|
+
// A Task still incurs one O(E) event projection. Group those observations
|
|
142
|
+
// by Run and sort each group once so every active Run can reuse the same
|
|
143
|
+
// ordered slice instead of repeatedly filtering/sorting all E events.
|
|
144
|
+
const observationsByRunId = new Map();
|
|
145
|
+
for (const event of this.store.listEvents(task.id)) {
|
|
146
|
+
const observation = runtimeObservationFromTaskEvent(event);
|
|
147
|
+
const runId = observation?.fence.runId;
|
|
148
|
+
if (observation === null || runId === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
const grouped = observationsByRunId.get(runId);
|
|
151
|
+
if (grouped === undefined) {
|
|
152
|
+
observationsByRunId.set(runId, [observation]);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
grouped.push(observation);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const observations of observationsByRunId.values()) {
|
|
159
|
+
observations.sort(compareObservations);
|
|
160
|
+
}
|
|
128
161
|
for (const run of this.store.listAgentRuns(task.id)) {
|
|
129
162
|
if (run.status !== "active"
|
|
130
163
|
|| this.store.getActiveAgentRun(task.id, run.roleName)?.id !== run.id)
|
|
131
164
|
continue;
|
|
165
|
+
const observations = observationsByRunId.get(run.id) ?? [];
|
|
132
166
|
const accepted = observations
|
|
133
167
|
.filter((observation) => observation.kind === "turn.accepted"
|
|
134
168
|
&& observation.fence.runId === run.id
|
|
135
169
|
&& observation.fence.roleName === run.roleName
|
|
136
170
|
&& observation.fence.agentId === run.effective.agentId
|
|
137
171
|
&& observation.payload.observerSource !== undefined)
|
|
138
|
-
.sort(compareObservations)
|
|
139
172
|
.at(-1);
|
|
140
173
|
const source = accepted?.payload.observerSource;
|
|
141
174
|
if (accepted === undefined || source === undefined
|
|
@@ -150,19 +183,23 @@ export class AgentRuntimeObserver {
|
|
|
150
183
|
}
|
|
151
184
|
const fence = accepted.fence;
|
|
152
185
|
const exact = observations
|
|
153
|
-
.filter((observation) => runtimeObservationFenceMatches(fence, observation.fence))
|
|
154
|
-
.sort(compareObservations);
|
|
186
|
+
.filter((observation) => runtimeObservationFenceMatches(fence, observation.fence));
|
|
155
187
|
const persistedUsage = exact.filter((observation) => (observation.kind === "activity.observed"
|
|
156
188
|
&& observation.payload.usage !== undefined)).at(-1);
|
|
157
189
|
const persistedHealth = exact.filter((observation) => (observation.kind === "observer.health"
|
|
158
190
|
&& observation.payload.sourceId === source.sourceId)).at(-1);
|
|
159
191
|
result.push(Object.freeze({
|
|
160
192
|
key: JSON.stringify([
|
|
193
|
+
fence.taskId,
|
|
194
|
+
fence.roleName,
|
|
195
|
+
fence.runId,
|
|
196
|
+
fence.agentId,
|
|
161
197
|
fence.driverId,
|
|
198
|
+
fence.launchId,
|
|
162
199
|
fence.sessionGenerationId,
|
|
163
200
|
fence.nativeSessionId,
|
|
164
201
|
fence.nativeTurnId,
|
|
165
|
-
fence.
|
|
202
|
+
fence.receiptId,
|
|
166
203
|
source.sourceId
|
|
167
204
|
]),
|
|
168
205
|
fence,
|
|
@@ -187,8 +224,31 @@ export class AgentRuntimeObserver {
|
|
|
187
224
|
}));
|
|
188
225
|
}
|
|
189
226
|
}
|
|
190
|
-
return result;
|
|
227
|
+
return Object.freeze(result.sort((left, right) => numericCompare(left.key, right.key)));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function forEachConcurrent(values, limit, visit) {
|
|
231
|
+
let nextIndex = 0;
|
|
232
|
+
const workers = Math.min(limit, values.length);
|
|
233
|
+
await Promise.all(Array.from({ length: workers }, async () => {
|
|
234
|
+
while (nextIndex < values.length) {
|
|
235
|
+
const index = nextIndex;
|
|
236
|
+
nextIndex += 1;
|
|
237
|
+
await visit(values[index], index);
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
function sampleConcurrency(value) {
|
|
242
|
+
const resolved = value ?? DEFAULT_MAX_CONCURRENT_SAMPLES;
|
|
243
|
+
if (!Number.isSafeInteger(resolved)
|
|
244
|
+
|| resolved < 1
|
|
245
|
+
|| resolved > MAX_CONCURRENT_SAMPLES) {
|
|
246
|
+
throw new Error(`Agent runtime observer maxConcurrentSamples must be between 1 and ${MAX_CONCURRENT_SAMPLES}.`);
|
|
191
247
|
}
|
|
248
|
+
return resolved;
|
|
249
|
+
}
|
|
250
|
+
function numericCompare(left, right) {
|
|
251
|
+
return left.localeCompare(right, undefined, { numeric: true });
|
|
192
252
|
}
|
|
193
253
|
function compareObservations(left, right) {
|
|
194
254
|
return left.receivedAt.localeCompare(right.receivedAt)
|