@zq-silk/yui 0.6.3 → 0.6.5
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 +18 -85
- package/dist/cli/updateOrchestrator.js +12 -6
- package/dist/cli.js +12 -0
- package/dist/storage/migration/productionRegistry.js +56 -12
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +2 -1
- package/dist/storage/upgrade/sqliteStateMigration.js +123 -0
- package/i18n/README.zh-CN.md +12 -3
- 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,
|
|
@@ -881,94 +889,19 @@ atomic storage switch, purely to make that single switch recoverable — it is n
|
|
|
881
889
|
a general backup/restore facility.)
|
|
882
890
|
|
|
883
891
|
See [ARCHITECTURE.md](./ARCHITECTURE.md) for persistence and scheduling details.
|
|
884
|
-
The reusable, user-driven acceptance plan is documented in
|
|
885
|
-
[Operator routing and long-running Task E2E testing](./docs/testing/operator-routing-e2e-plan.md).
|
|
886
|
-
|
|
887
892
|
## Development
|
|
888
893
|
|
|
889
894
|
```sh
|
|
890
895
|
npm ci
|
|
891
|
-
npm
|
|
892
|
-
```
|
|
893
|
-
|
|
894
|
-
`npm run test:core` builds once and runs the same small, explicit core matrix as
|
|
895
|
-
CI. During development, add the smallest affected test files for the behavior
|
|
896
|
-
being changed.
|
|
897
|
-
|
|
898
|
-
`npm test` (and `make test` / `make check`) remains the full **deterministic**
|
|
899
|
-
diagnostic suite. Run it on demand for unusually cross-cutting changes or
|
|
900
|
-
regression investigation; it is not a routine merge requirement. It never
|
|
901
|
-
launches a real model or touches the global `yui` binary, a shared `YUI_HOME`,
|
|
902
|
-
or a running production Session. It stays
|
|
903
|
-
deterministic even when launched from inside a managed Yui Session, because it
|
|
904
|
-
preloads `test/helpers/scrubSessionEnv.js` to strip every Yui-owned managed
|
|
905
|
-
runtime value from the test process, including shared `YUI_HOME`, exact Leader
|
|
906
|
-
action assertions, workspace projections, and Agent launch descriptors. Tests
|
|
907
|
-
that touch Home/CLI/Controller/tmux explicitly supply a test-created isolated
|
|
908
|
-
Home. The same preamble puts local refusal shims for bare `codex` and `claude`
|
|
909
|
-
ahead of the caller's `PATH`; Session fixtures install observable Mock Agents
|
|
910
|
-
inside their owned Home instead. Only a dedicated managed-identity child may
|
|
911
|
-
opt out. The Provider E2E tier is exempt from the shims only after its explicit
|
|
912
|
-
opt-in and mandatory isolation preflight path has been selected.
|
|
913
|
-
|
|
914
|
-
### Test tiers
|
|
915
|
-
|
|
916
|
-
Yui's tests are classified into five explicit, executable tiers so a reader
|
|
917
|
-
never has to guess what a test actually exercised. Each tier declares whether it
|
|
918
|
-
creates a Session, whether it calls a real model, and whether it stands up a
|
|
919
|
-
disposable real runtime. Agent workflow for applying these tiers while developing
|
|
920
|
-
Yui lives in [`.agents/skills/develop-yui/SKILL.md`](.agents/skills/develop-yui/SKILL.md); it is
|
|
921
|
-
not part of the generic Leader, Worker, or Reviewer workflow:
|
|
922
|
-
|
|
923
|
-
| Tier | Session | Real model | Disposable runtime | Preflight | Opt-in |
|
|
924
|
-
| --- | --- | --- | --- | --- | --- |
|
|
925
|
-
| Unit | no | no | no | no | — |
|
|
926
|
-
| Isolated Integration | yes | no | yes | no | — |
|
|
927
|
-
| Mock Agent Session | yes | no | yes | no | — |
|
|
928
|
-
| Provider E2E | yes | **yes** | yes | **required** | `YUI_ALLOW_PROVIDER_E2E=1` |
|
|
929
|
-
| Release E2E | **no** | **no** | yes | **required** | `YUI_ALLOW_RELEASE_E2E=1` |
|
|
930
|
-
|
|
931
|
-
```sh
|
|
932
|
-
make test-tier T=unit # or: npm run test:tier -- unit
|
|
933
|
-
npm run test:tier -- unit -- --test-name-pattern "test name"
|
|
934
|
-
node scripts/run-test-tier.mjs list
|
|
896
|
+
npm test
|
|
935
897
|
```
|
|
936
898
|
|
|
937
|
-
The
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
normal path, creates no Session and calls no model — it exercises
|
|
944
|
-
binary/install/update/upgrade release flows against real npm/home/namespace
|
|
945
|
-
resources. Both tiers are **privileged and fail-closed**: they live only in
|
|
946
|
-
nested privileged manifests excluded from the default test glob, refuse to run
|
|
947
|
-
without their opt-in env var, and execute through one wrapper that registers
|
|
948
|
-
cleanup before observation and does not even evaluate the scenario module until
|
|
949
|
-
the blocking isolation preflight (`assertIsolationReady`) passes. Active-Session
|
|
950
|
-
observation is runner-owned and uses an all-scope Yui runtime inventory;
|
|
951
|
-
scenario code cannot replace it or manufacture an empty result. The preflight
|
|
952
|
-
requires an absolute
|
|
953
|
-
checkout-local launcher; a run root proven **temporary and creator-bound owned by
|
|
954
|
-
this run** — created via `createOwnedRunRoot` (mkdtemp + a random-token
|
|
955
|
-
ownership receipt) and re-proven by that exact token, with a symlink run root
|
|
956
|
-
refused and every path canonicalized so a symlink escape cannot pass a lexical
|
|
957
|
-
check; the disposable `YUI_HOME`, workspace, isolated npm prefix, and unique
|
|
958
|
-
runtime namespace all derived *inside that exact owned root* and **physically
|
|
959
|
-
fenced** against symlink escape; and an **explicit** observation that zero
|
|
960
|
-
production Sessions are active (missing evidence fails closed — it is never
|
|
961
|
-
assumed empty). No bare `yui`, `make link` symlink, shared home, arbitrary or
|
|
962
|
-
pre-existing foreign run root, symlinked path, or unproven Session state is
|
|
963
|
-
tolerated. Real-runtime teardown scans and cleans only the creator-owned Home,
|
|
964
|
-
uses Yui's exact process/pane/artifact identity fences, verifies the Home-derived
|
|
965
|
-
tmux server is absent, and refuses environment overrides that redirect
|
|
966
|
-
`YUI_HOME`. The reusable annotated-resource selector separately requires an
|
|
967
|
-
exact non-empty creator token plus matching `ephemeral-test` marker; a missing
|
|
968
|
-
token touches nothing and is a failed cleanup outcome. **Mock Agent Session
|
|
969
|
-
transport success does not prove
|
|
970
|
-
provider-native acceptance** — only the Provider E2E tier can record that. See
|
|
971
|
-
[docs/testing/test-tiers.md](./docs/testing/test-tiers.md) for the full contract.
|
|
899
|
+
The permanent suite is intentionally one seconds-scale core smoke. It checks
|
|
900
|
+
CLI startup, a normal SQLite Task path, the supported migration graph, and the
|
|
901
|
+
built-in Agent Drivers. Change-specific TDD fixtures and abnormal-data repros
|
|
902
|
+
are temporary development evidence and are removed when the change is complete;
|
|
903
|
+
they do not accumulate as permanent regression tests. See
|
|
904
|
+
[the verification policy](./docs/testing/verification-levels.md).
|
|
972
905
|
|
|
973
906
|
To make user terminals use this checkout, reversibly link the user-level `yui` command:
|
|
974
907
|
|
|
@@ -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;
|
|
@@ -1303,24 +1303,68 @@ function requireAggregateV17Snapshot(snapshot) {
|
|
|
1303
1303
|
/**
|
|
1304
1304
|
* Runtime state is now projected exclusively from canonical
|
|
1305
1305
|
* `runtime.observation` events. Offline upgrade inventory proves there are no
|
|
1306
|
-
* active Runs or live Sessions
|
|
1307
|
-
*
|
|
1306
|
+
* active Runs or live Sessions. A retired Task has also explicitly abandoned
|
|
1307
|
+
* its runtime, so its stored runtime inconsistencies no longer block the Home;
|
|
1308
|
+
* the anomalous records themselves remain available as history. Non-retired
|
|
1309
|
+
* Tasks still fail closed and receive the supported retirement command.
|
|
1308
1310
|
*/
|
|
1309
1311
|
function migrateAggregateV18ToV19(snapshot) {
|
|
1310
1312
|
requireAggregateV18Snapshot(snapshot);
|
|
1313
|
+
const schemaManifest = {
|
|
1314
|
+
...snapshot.schemaManifest,
|
|
1315
|
+
aggregateSchemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
|
|
1316
|
+
};
|
|
1317
|
+
if (snapshot.state === null)
|
|
1318
|
+
return { schemaManifest, state: null };
|
|
1319
|
+
const tasks = asObject(snapshot.state.tasks, "state tasks");
|
|
1320
|
+
const nextTasks = {};
|
|
1321
|
+
for (const [taskId, rawStoredTask] of Object.entries(tasks)) {
|
|
1322
|
+
const storedTask = asObject(rawStoredTask, `Task aggregate ${taskId}`);
|
|
1323
|
+
const rawTask = storedTask.task;
|
|
1324
|
+
const task = rawTask !== null && typeof rawTask === "object" && !Array.isArray(rawTask)
|
|
1325
|
+
? rawTask
|
|
1326
|
+
: undefined;
|
|
1327
|
+
if (task?.status === "retired") {
|
|
1328
|
+
nextTasks[taskId] = { ...storedTask };
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
requireResolvableActiveRunPointers(taskId, storedTask);
|
|
1332
|
+
nextTasks[taskId] = { ...storedTask };
|
|
1333
|
+
}
|
|
1311
1334
|
return {
|
|
1312
|
-
schemaManifest
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
: {
|
|
1319
|
-
...snapshot.state,
|
|
1320
|
-
schemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
|
|
1321
|
-
}
|
|
1335
|
+
schemaManifest,
|
|
1336
|
+
state: {
|
|
1337
|
+
...snapshot.state,
|
|
1338
|
+
schemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION,
|
|
1339
|
+
tasks: nextTasks
|
|
1340
|
+
}
|
|
1322
1341
|
};
|
|
1323
1342
|
}
|
|
1343
|
+
function requireResolvableActiveRunPointers(taskId, storedTask) {
|
|
1344
|
+
if (storedTask.activeRuns === undefined)
|
|
1345
|
+
return;
|
|
1346
|
+
const activeRuns = asObject(storedTask.activeRuns, `activeRunPointer map ${taskId}`);
|
|
1347
|
+
if (Object.keys(activeRuns).length === 0)
|
|
1348
|
+
return;
|
|
1349
|
+
const agentRuns = asObject(storedTask.agentRuns, `agentRun map ${taskId}`);
|
|
1350
|
+
for (const [pointer, rawActiveRun] of Object.entries(activeRuns)) {
|
|
1351
|
+
const activeRun = asObject(rawActiveRun, `Active run ${taskId}/${pointer}`);
|
|
1352
|
+
const runId = typeof activeRun.runId === "string" ? activeRun.runId.trim() : "";
|
|
1353
|
+
if (runId.length === 0) {
|
|
1354
|
+
throw new Error(`Active run pointer ${taskId}/${pointer} has an invalid runId. `
|
|
1355
|
+
+ taskRetirementUpgradeHint(taskId));
|
|
1356
|
+
}
|
|
1357
|
+
if (agentRuns[runId] === undefined) {
|
|
1358
|
+
throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${runId}. `
|
|
1359
|
+
+ taskRetirementUpgradeHint(taskId));
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function taskRetirementUpgradeHint(taskId) {
|
|
1364
|
+
return `Retire Task ${taskId} with `
|
|
1365
|
+
+ `\`yui task retire ${taskId} --summary "abandon inconsistent runtime state"\`, `
|
|
1366
|
+
+ "then retry `yui update`.";
|
|
1367
|
+
}
|
|
1324
1368
|
function requireAggregateV18Snapshot(snapshot) {
|
|
1325
1369
|
if (snapshot.schemaManifest.aggregateSchemaVersion
|
|
1326
1370
|
!== RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION) {
|
|
@@ -37,7 +37,7 @@ import { planMigration } from "../migration/planner.js";
|
|
|
37
37
|
import { AmbiguousSwitchError } from "../migration/index.js";
|
|
38
38
|
import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
|
|
39
39
|
import { writeSwitchProgress } from "./switchProgress.js";
|
|
40
|
-
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
|
|
40
|
+
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
|
|
41
41
|
/** Build the SQLite-backed record-migration target. */
|
|
42
42
|
export function createSqliteRecordMigrationTarget(options) {
|
|
43
43
|
const home = options.home;
|
|
@@ -92,6 +92,7 @@ export function createSqliteRecordMigrationTarget(options) {
|
|
|
92
92
|
updatedAt: now().toISOString()
|
|
93
93
|
};
|
|
94
94
|
populateSqliteFromState(home, snapshot.state ?? {}, STAGED_DATABASE_FILENAME);
|
|
95
|
+
copySqlitePassthroughState(home, COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME);
|
|
95
96
|
},
|
|
96
97
|
rebuildDerivedState(effects) {
|
|
97
98
|
// The SQLite database is fully normalised by populateSqliteFromState;
|
|
@@ -33,6 +33,90 @@ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
|
|
|
33
33
|
export const STAGED_DATABASE_FILENAME = "yui.db.staged";
|
|
34
34
|
/** The committed database filename. */
|
|
35
35
|
export const COMMITTED_DATABASE_FILENAME = "yui.db";
|
|
36
|
+
/**
|
|
37
|
+
* Preserve SQLite-owned durable state that intentionally sits outside the
|
|
38
|
+
* state.json-shaped Task aggregate snapshot. Volatile coordination locks and
|
|
39
|
+
* derived projections are rebuilt or dropped at the offline boundary.
|
|
40
|
+
*/
|
|
41
|
+
export function copySqlitePassthroughState(home, sourceDatabaseFilename, targetDatabaseFilename) {
|
|
42
|
+
if (sourceDatabaseFilename === targetDatabaseFilename) {
|
|
43
|
+
throw new Error("SQLite passthrough copy requires distinct source and target databases.");
|
|
44
|
+
}
|
|
45
|
+
const source = new Database(join(home, sourceDatabaseFilename), { readonly: true });
|
|
46
|
+
const target = new Database(join(home, targetDatabaseFilename));
|
|
47
|
+
try {
|
|
48
|
+
target.pragma("foreign_keys = ON");
|
|
49
|
+
target.transaction(() => {
|
|
50
|
+
mergeGlobalSequences(source, target);
|
|
51
|
+
copyTableRows(source, target, "outbox");
|
|
52
|
+
copyMailboxSignals(source, target);
|
|
53
|
+
copyTableRows(source, target, "work_item_candidates");
|
|
54
|
+
copyTableRows(source, target, "review_findings");
|
|
55
|
+
copyTableRows(source, target, "telemetry");
|
|
56
|
+
if (sqliteTableExists(source, "telemetry_aggregate")) {
|
|
57
|
+
target.exec("DELETE FROM telemetry_aggregate");
|
|
58
|
+
copyTableRows(source, target, "telemetry_aggregate");
|
|
59
|
+
}
|
|
60
|
+
copyTableRows(source, target, "session_owners");
|
|
61
|
+
copyTableRows(source, target, "resource_registry");
|
|
62
|
+
copyTableRows(source, target, "gate_artifacts");
|
|
63
|
+
copyTableRows(source, target, "gate_artifact_logs");
|
|
64
|
+
})();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
source.close();
|
|
68
|
+
target.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function mergeGlobalSequences(source, target) {
|
|
72
|
+
if (!sqliteTableExists(source, "global_sequences"))
|
|
73
|
+
return;
|
|
74
|
+
const rows = source.prepare("SELECT name, high_water FROM global_sequences").all();
|
|
75
|
+
const merge = target.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
|
|
76
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
77
|
+
high_water = MAX(global_sequences.high_water, excluded.high_water)`);
|
|
78
|
+
for (const row of rows)
|
|
79
|
+
merge.run(row.name, row.high_water);
|
|
80
|
+
}
|
|
81
|
+
function copyMailboxSignals(source, target) {
|
|
82
|
+
if (!sqliteTableExists(source, "mailbox_signals"))
|
|
83
|
+
return;
|
|
84
|
+
const rows = source.prepare(`SELECT m.target_key, s.sequence, s.reason, s.ref_type, s.ref_task_id,
|
|
85
|
+
s.ref_id, s.occurred_at, s.request_id
|
|
86
|
+
FROM mailbox_signals s
|
|
87
|
+
JOIN mailboxes m ON m.mailbox_id = s.mailbox_id
|
|
88
|
+
ORDER BY m.target_key, s.sequence`).iterate();
|
|
89
|
+
const findMailbox = target.prepare("SELECT mailbox_id FROM mailboxes WHERE target_key = ?");
|
|
90
|
+
const insert = target.prepare(`INSERT INTO mailbox_signals
|
|
91
|
+
(mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
|
|
92
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const mailbox = findMailbox.get(row.target_key);
|
|
95
|
+
if (mailbox === undefined) {
|
|
96
|
+
throw new Error(`SQLite migration cannot preserve signals for missing mailbox ${row.target_key}.`);
|
|
97
|
+
}
|
|
98
|
+
insert.run(mailbox.mailbox_id, row.sequence, row.reason, row.ref_type, row.ref_task_id, row.ref_id, row.occurred_at, row.request_id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function copyTableRows(source, target, table) {
|
|
102
|
+
if (!sqliteTableExists(source, table) || !sqliteTableExists(target, table))
|
|
103
|
+
return;
|
|
104
|
+
const columns = source.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all().map((row) => row.name);
|
|
105
|
+
if (columns.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
const quotedColumns = columns.map(quoteSqliteIdentifier);
|
|
108
|
+
const rows = source.prepare(`SELECT ${quotedColumns.join(", ")} FROM ${quoteSqliteIdentifier(table)}`).iterate();
|
|
109
|
+
const insert = target.prepare(`INSERT INTO ${quoteSqliteIdentifier(table)} (${quotedColumns.join(", ")}) `
|
|
110
|
+
+ `VALUES (${columns.map(() => "?").join(", ")})`);
|
|
111
|
+
for (const row of rows)
|
|
112
|
+
insert.run(...columns.map((column) => row[column]));
|
|
113
|
+
}
|
|
114
|
+
function sqliteTableExists(db, table) {
|
|
115
|
+
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
|
|
116
|
+
}
|
|
117
|
+
function quoteSqliteIdentifier(value) {
|
|
118
|
+
return `"${value.replaceAll("\"", "\"\"")}"`;
|
|
119
|
+
}
|
|
36
120
|
// ---------------------------------------------------------------------------
|
|
37
121
|
// Canonical JSON and hashing
|
|
38
122
|
// ---------------------------------------------------------------------------
|
|
@@ -157,6 +241,7 @@ function tasksOf(state) {
|
|
|
157
241
|
* sidecar database file.
|
|
158
242
|
*/
|
|
159
243
|
export function populateSqliteFromState(home, state, databaseFilename) {
|
|
244
|
+
const retiredActiveRuns = [];
|
|
160
245
|
const store = new SqliteTaskStore(home, { databaseFilename, migration: true });
|
|
161
246
|
try {
|
|
162
247
|
store.transaction(() => {
|
|
@@ -234,6 +319,17 @@ export function populateSqliteFromState(home, state, databaseFilename) {
|
|
|
234
319
|
// Active-run pointers: the document stores { schemaVersion, runId }
|
|
235
320
|
// keyed by pointer; the store derives the pointer from the Run.
|
|
236
321
|
for (const [pointer, value] of Object.entries(stored.activeRuns)) {
|
|
322
|
+
if (stored.task.status === "retired") {
|
|
323
|
+
retiredActiveRuns.push({
|
|
324
|
+
taskId,
|
|
325
|
+
pointer,
|
|
326
|
+
value,
|
|
327
|
+
updatedAt: typeof stored.task.updatedAt === "string"
|
|
328
|
+
? stored.task.updatedAt
|
|
329
|
+
: new Date().toISOString()
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
237
333
|
const run = stored.agentRuns[value.runId];
|
|
238
334
|
if (run === undefined) {
|
|
239
335
|
throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${value.runId}.`);
|
|
@@ -291,6 +387,33 @@ export function populateSqliteFromState(home, state, databaseFilename) {
|
|
|
291
387
|
finally {
|
|
292
388
|
store.close();
|
|
293
389
|
}
|
|
390
|
+
persistRetiredActiveRunPointers(home, databaseFilename, retiredActiveRuns);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* A retired Task is an explicit isolation boundary. Its active-run rows are
|
|
394
|
+
* retained byte-for-byte at the logical record level even when their Run is
|
|
395
|
+
* missing; normal Tasks continue through the referentially strict store path.
|
|
396
|
+
*/
|
|
397
|
+
function persistRetiredActiveRunPointers(home, databaseFilename, pointers) {
|
|
398
|
+
if (pointers.length === 0)
|
|
399
|
+
return;
|
|
400
|
+
const db = new Database(join(home, databaseFilename));
|
|
401
|
+
try {
|
|
402
|
+
const insert = db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at)
|
|
403
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
404
|
+
db.transaction(() => {
|
|
405
|
+
for (const entry of pointers) {
|
|
406
|
+
if (typeof entry.value.runId !== "string") {
|
|
407
|
+
throw new Error(`Retired Task active run pointer ${entry.taskId}/${entry.pointer} `
|
|
408
|
+
+ "cannot be represented because runId is not a string.");
|
|
409
|
+
}
|
|
410
|
+
insert.run(entry.taskId, entry.pointer, entry.value.runId, JSON.stringify(entry.value), entry.updatedAt);
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
db.close();
|
|
416
|
+
}
|
|
294
417
|
}
|
|
295
418
|
/**
|
|
296
419
|
* Seed `global_sequences` from the numeric suffixes of existing task and
|
package/i18n/README.zh-CN.md
CHANGED
|
@@ -52,7 +52,8 @@ Leader 和 Operator,再说明全局 Worker 配置会复制到新建的 Task Ro
|
|
|
52
52
|
支持的思考强度。随后 setup 会确认位于 Yui home 外部的 Project workspace,
|
|
53
53
|
并询问 shell completion。选择器同时提供原生 CLI 默认值和自定义值入口。
|
|
54
54
|
再次运行不会删除已有 Task/Role,也不会改变当前安装的 Project workspace,
|
|
55
|
-
可用于安全地调整配置。
|
|
55
|
+
可用于安全地调整配置。setup 成功返回前会确保当前 Home 的后台 Controller
|
|
56
|
+
已经启动。
|
|
56
57
|
|
|
57
58
|
模型与思考强度属于 Agent binding 设置,因此 Operator、Leader 和全局
|
|
58
59
|
Worker 即使使用同一个 Agent CLI,也可以采用不同配置。Profile 中的
|
|
@@ -477,6 +478,11 @@ yui controller restart
|
|
|
477
478
|
|
|
478
479
|
`controller restart` 会用当前安装的 Yui 版本替换 Controller 进程及其调度循环、socket 服务,不会停止或重启已受管的 tmux/Agent 会话。
|
|
479
480
|
|
|
481
|
+
成功的 `setup`、`upgrade` 和 `update` 都会确保当前 Home 有一个运行中的
|
|
482
|
+
Controller;如果之前没有运行,会在完成后启动。只读命令和
|
|
483
|
+
`upgrade --dry-run` 不会启动 Controller。`update` 只有在新二进制健康检查通过后,
|
|
484
|
+
才会替换或启动 Controller。
|
|
485
|
+
|
|
480
486
|
恢复 reconciliation 默认每 120 秒执行一次。普通持久状态变化只会将 Task、Role 或 Operator key 放入队列并立即返回;固定 100ms 窗口内到达的 key 会合并触发一次不重叠的定向处理。Operator 呈现使用独立 lane,不会被 Task 的 Git/worktree 操作阻塞;周期 Git/worktree 处理只覆盖仍有持久 Task mailbox 工作的 Task,活动 Role 的存活检查合并为一次 tmux inventory。Codex turn-complete Hook 直接写入存储,不启动或等待 Controller,并给合法的 yield、输入请求或完成动作保留 2 秒竞争窗口;到期后才关闭被 Agent 遗忘的活动 Role Run。持久 WorkMailbox 会冻结当前 processing 批次,期间的新事件合并到下一 pending 批次;失败会释放当前批次供恢复。推荐输入与 pending Turn 共用最近 deadline 选择器,不依赖恢复扫描间隔;显式 `task reconcile` 仍会立即请求恢复扫描。保留的闭环为:
|
|
481
487
|
|
|
482
488
|
1. 准备 active Project Task 的主 worktree;
|
|
@@ -534,8 +540,6 @@ Agent 环境变量绑定只保存进程环境变量名,不保存 secret 值;
|
|
|
534
540
|
Yui 面向一台机器上的一个受信任本地用户。它的 Web/API 仅支持 loopback,不包含远程或多用户 Web、分布式协调、backup/import/export、trash/restore、derived index、recovery journal、runtime lease、inactivity TTL、cooldown 或 recurring schedule。
|
|
535
541
|
|
|
536
542
|
持久化和调度细节见 [ARCHITECTURE.md](../ARCHITECTURE.md)。
|
|
537
|
-
可复用的用户视角验收方案见
|
|
538
|
-
[Operator 路由与长期任务端到端测试方案](../docs/testing/operator-routing-e2e-plan.md)。
|
|
539
543
|
|
|
540
544
|
## 本地开发
|
|
541
545
|
|
|
@@ -546,6 +550,11 @@ npm test
|
|
|
546
550
|
npm run lint
|
|
547
551
|
```
|
|
548
552
|
|
|
553
|
+
`npm test` 只保留秒级核心 smoke:CLI 启动、正常 SQLite Task、受支持迁移和内置
|
|
554
|
+
Agent Driver。针对当前修改编写的 TDD、异常数据和故障复现仅作为开发期证据,需求完成后
|
|
555
|
+
删除,不累积为常驻回归测试。具体约束见
|
|
556
|
+
[验证策略](../docs/testing/verification-levels.md)。
|
|
557
|
+
|
|
549
558
|
如需让用户终端使用当前 checkout,可逆地接管用户级 `yui` 命令:
|
|
550
559
|
|
|
551
560
|
```sh
|