@zq-silk/yui 0.12.0 → 0.12.2
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 +8 -9
- package/dist/cli/commandCatalog.js +11 -6
- package/dist/cli/interactionPolicy.js +5 -6
- package/dist/cli/updateCommand.js +3 -1
- package/dist/cli/updateOrchestrator.js +173 -28
- package/dist/cli/updatePorts.js +137 -8
- package/dist/cli/upgradeCommand.js +19 -9
- package/dist/cli.js +51 -14
- package/dist/commands/configCommands.js +1 -1
- package/dist/commands/executionAuditCommands.js +2 -1
- package/dist/commands/taskCommands.js +80 -29
- package/dist/commands/taskContextCommand.js +6 -2
- package/dist/commands/taskRoleRuntimeStatus.js +31 -7
- package/dist/config/configCatalog.js +1 -1
- package/dist/controller/clientRuntime.js +38 -2
- package/dist/controller/controller.js +23 -15
- package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
- package/dist/controller/runtime.js +56 -1
- package/dist/controller/runtimeHookRunFence.js +19 -4
- package/dist/controller/structuredProviderObservation.js +20 -3
- package/dist/core/controllerClient.js +20 -2
- package/dist/core/controllerServer.js +1 -0
- package/dist/executor/agentExecutor.js +48 -46
- package/dist/executor/fileRoleLaunchPlanner.js +94 -30
- package/dist/lifecycle/exactRunTerminalization.js +68 -3
- package/dist/observability/executionAudit.js +5 -0
- package/dist/release/runtimeRelease.js +20 -0
- package/dist/run/recoveryProjection.js +45 -6
- package/dist/runtime/agentHost.js +159 -85
- package/dist/runtime/conversationSwitch.js +277 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/launchBroker.js +12 -0
- package/dist/runtime/processExitOutbox.js +88 -0
- package/dist/runtime/providerRuntimeIdentity.js +29 -1
- package/dist/runtime/runtimeHealthPolicy.js +5 -5
- package/dist/runtime/runtimeObservation.js +15 -0
- package/dist/runtime/runtimeProjection.js +6 -7
- package/dist/runtime/tmuxAdapters.js +4 -1
- package/dist/scheduler/activeRoleRunDelivery.js +31 -196
- package/dist/scheduler/leaderWakeupProcessor.js +39 -133
- package/dist/scheduler/roleRunStall.js +53 -17
- package/dist/storage/sqliteSchema.js +62 -26
- package/dist/storage/sqliteStore.js +23 -4
- package/dist/storage/upgrade/homeClassification.js +52 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
- package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
- package/dist/task/nextAction.js +0 -34
- package/dist/web/webSnapshot.js +3 -1
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +7 -4
- package/skills/yui-operator/SKILL.md +4 -4
- package/skills/yui-reviewer/SKILL.md +7 -4
- package/dist/lifecycle/taskRoleSessionReset.js +0 -118
|
@@ -37,6 +37,10 @@ export const TELEMETRY_RUN_CAP = 50_000;
|
|
|
37
37
|
* `synchronous`/`foreign_keys`/`busy_timeout` are per-connection PRAGMAs set by
|
|
38
38
|
* the store; `journal_mode=WAL` is a persistent database property set on open.
|
|
39
39
|
* The migration itself only contains schema objects.
|
|
40
|
+
*
|
|
41
|
+
* This released SQL string, including its comments, is checksummed persistent
|
|
42
|
+
* history and must remain byte-for-byte unchanged. The `operator-notification`
|
|
43
|
+
* projection kind it creates is retained only for old layout-7 Homes.
|
|
40
44
|
*/
|
|
41
45
|
const MIGRATION_1_SQL = `
|
|
42
46
|
-- Global catalog and coordination (§4.1) -------------------------------------
|
|
@@ -327,8 +331,7 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
327
331
|
);
|
|
328
332
|
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(task_id, type, occurred_at);
|
|
329
333
|
|
|
330
|
-
-- Per-task scheduler projections
|
|
331
|
-
-- kind retained only so old layout-7 Homes can be read by the upgrade path.
|
|
334
|
+
-- Per-task scheduler projections (leaderFailure, operatorNotification).
|
|
332
335
|
CREATE TABLE IF NOT EXISTS task_projections (
|
|
333
336
|
task_id TEXT NOT NULL,
|
|
334
337
|
kind TEXT NOT NULL CHECK (kind IN ('leader-failure','operator-notification')),
|
|
@@ -931,6 +934,12 @@ CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
|
931
934
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_context_snapshots_scope_sequence
|
|
932
935
|
ON context_snapshots(task_id, scope, COALESCE(scope_ref, ''), sequence);
|
|
933
936
|
`;
|
|
937
|
+
/**
|
|
938
|
+
* The single forward migration history for current SQLite Homes. New durable
|
|
939
|
+
* layout, aggregate, and record changes belong here and must be expressible as
|
|
940
|
+
* one atomic in-place transaction. The separate logical migration registry is
|
|
941
|
+
* only the bridge from valid pre-SQLite Homes and is allowed to rebuild them.
|
|
942
|
+
*/
|
|
934
943
|
const MIGRATIONS = [
|
|
935
944
|
{ version: 1, axis: "layout", sql: MIGRATION_1_SQL },
|
|
936
945
|
{ version: 2, axis: "record", recordKind: "durableJob+capability-grant+release-workflow", sql: MIGRATION_2_SQL },
|
|
@@ -1137,41 +1146,68 @@ function validatePendingProjectionBackfillSources(db) {
|
|
|
1137
1146
|
throw new SqliteSchemaMigrationError(`pending Turn completion source is invalid for ${mismatch.task_id}/${mismatch.role_name}`, "backfill");
|
|
1138
1147
|
}
|
|
1139
1148
|
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Inspect a database's checksummed migration prefix without changing it.
|
|
1151
|
+
* Pending versions are a supported upgrade state; malformed, gapped, changed,
|
|
1152
|
+
* or future ledger entries remain explicit integrity failures.
|
|
1153
|
+
*/
|
|
1154
|
+
export function inspectSqliteSchemaMigrations(db) {
|
|
1155
|
+
const ledgerWasCreated = ensureMigrationLedger(db, "validate");
|
|
1156
|
+
const applied = validateAppliedMigrations(db, ledgerWasCreated);
|
|
1157
|
+
const pendingVersions = MIGRATIONS
|
|
1158
|
+
.filter((migration) => !applied.has(migration.version))
|
|
1159
|
+
.map((migration) => migration.version);
|
|
1160
|
+
if (pendingVersions.length === 0)
|
|
1161
|
+
validateSchemaObjects(db);
|
|
1162
|
+
const current = MIGRATIONS[applied.size - 1];
|
|
1163
|
+
const target = MIGRATIONS.at(-1);
|
|
1164
|
+
if (current === undefined) {
|
|
1165
|
+
throw new SqliteSchemaMigrationError("schema_migrations ledger has no current head");
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
currentVersion: applied.size,
|
|
1169
|
+
currentChecksum: checksum(current.sql),
|
|
1170
|
+
targetVersion: SQLITE_SCHEMA_VERSION,
|
|
1171
|
+
targetChecksum: checksum(target.sql),
|
|
1172
|
+
pendingVersions
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1140
1175
|
/**
|
|
1141
1176
|
* Apply or validate schema migrations without letting an ordinary open mutate
|
|
1142
1177
|
* an existing authoritative database.
|
|
1143
1178
|
*
|
|
1144
|
-
* In `apply` mode
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1179
|
+
* In `apply` mode every pending DDL/data step and every ledger row runs in one
|
|
1180
|
+
* outer transaction. The database therefore advances to the release version
|
|
1181
|
+
* as one commit or remains entirely at its previous version. `validate` mode
|
|
1182
|
+
* rejects a pending version before executing any migration.
|
|
1148
1183
|
*/
|
|
1149
1184
|
export function migrateSqliteSchema(db, options) {
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
continue;
|
|
1164
|
-
const apply = db.transaction(() => {
|
|
1185
|
+
const migrate = () => {
|
|
1186
|
+
const ledgerWasCreated = ensureMigrationLedger(db, options.mode);
|
|
1187
|
+
// Validate the complete ledger before touching any pending migration. This
|
|
1188
|
+
// prevents a manually altered or partially recorded ledger from silently
|
|
1189
|
+
// skipping a later schema/data step.
|
|
1190
|
+
const applied = validateAppliedMigrations(db, ledgerWasCreated);
|
|
1191
|
+
const pending = MIGRATIONS.filter((migration) => !applied.has(migration.version));
|
|
1192
|
+
if (options.mode === "validate" && pending.length > 0) {
|
|
1193
|
+
throw new SqliteSchemaMigrationError(`pending SQLite schema migration ${pending[0].version}; `
|
|
1194
|
+
+ "run the explicit storage upgrade before opening this database", "admission");
|
|
1195
|
+
}
|
|
1196
|
+
const newlyApplied = [];
|
|
1197
|
+
for (const migration of pending) {
|
|
1165
1198
|
if (migration.version === 13)
|
|
1166
1199
|
validatePendingProjectionBackfillSources(db);
|
|
1167
1200
|
db.exec(migration.sql);
|
|
1168
1201
|
db.prepare(`INSERT INTO schema_migrations (version, axis, record_kind, applied_at, checksum)
|
|
1169
1202
|
VALUES (?, ?, ?, ?, ?)`).run(migration.version, migration.axis, migration.recordKind ?? null, new Date().toISOString(), checksum(migration.sql));
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1203
|
+
newlyApplied.push(migration.version);
|
|
1204
|
+
}
|
|
1205
|
+
validateSchemaObjects(db);
|
|
1206
|
+
return newlyApplied;
|
|
1207
|
+
};
|
|
1208
|
+
const newlyApplied = options.mode === "apply" && !db.inTransaction
|
|
1209
|
+
? db.transaction(migrate)()
|
|
1210
|
+
: migrate();
|
|
1175
1211
|
return { applied: newlyApplied, version: SQLITE_SCHEMA_VERSION };
|
|
1176
1212
|
}
|
|
1177
1213
|
/** The names of every table the schema creates (for tests/introspection). */
|
|
@@ -55,7 +55,7 @@ import { assertHomeWritable } from "./upgradeFence.js";
|
|
|
55
55
|
import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, executionLaneActiveRunKey, executionLaneActiveRunKeyParts, StorageConflictError, StorageCancelledError, StorageRecordError, FileTaskStore, storedCapabilityGrant, storedPublicationReference, storedReleaseWorkflow, isValidCapabilityGrantTransition, isValidReleaseWorkflowTransition, validateYuiConfig } from "./taskStore.js";
|
|
56
56
|
import { publicationExternalKey } from "../task/publicationReference.js";
|
|
57
57
|
import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
|
|
58
|
-
import { migrateSqliteSchema, SqliteSchemaMigrationError, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
|
|
58
|
+
import { inspectSqliteSchemaMigrations, migrateSqliteSchema, SqliteSchemaMigrationError, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
|
|
59
59
|
import { inspectStorageSchema } from "./storageSchema.js";
|
|
60
60
|
/** Read the immutable Home identity without opening a writable Store connection. */
|
|
61
61
|
export function readSqliteHomeIdentity(rootDir, databaseFilename = "yui.db") {
|
|
@@ -136,6 +136,7 @@ export class SqliteTaskStore {
|
|
|
136
136
|
#db;
|
|
137
137
|
#rootDir;
|
|
138
138
|
#migration;
|
|
139
|
+
#openedSchemaHead;
|
|
139
140
|
#inTransaction = false;
|
|
140
141
|
#dirty = false;
|
|
141
142
|
constructor(rootDir, _options = {}) {
|
|
@@ -159,6 +160,11 @@ export class SqliteTaskStore {
|
|
|
159
160
|
migrateSqliteSchema(this.#db, {
|
|
160
161
|
mode: this.#migration || !databaseExisted ? "apply" : "validate"
|
|
161
162
|
});
|
|
163
|
+
const schema = inspectSqliteSchemaMigrations(this.#db);
|
|
164
|
+
this.#openedSchemaHead = {
|
|
165
|
+
version: schema.currentVersion,
|
|
166
|
+
checksum: schema.currentChecksum
|
|
167
|
+
};
|
|
162
168
|
this.#seedHomeMeta();
|
|
163
169
|
this.#seedConfig();
|
|
164
170
|
}
|
|
@@ -215,6 +221,13 @@ export class SqliteTaskStore {
|
|
|
215
221
|
if (this.#migration)
|
|
216
222
|
return;
|
|
217
223
|
assertHomeWritable(this.#rootDir);
|
|
224
|
+
const current = this.#db.prepare("SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1").get();
|
|
225
|
+
if (current?.version !== this.#openedSchemaHead.version
|
|
226
|
+
|| current.checksum !== this.#openedSchemaHead.checksum) {
|
|
227
|
+
throw new SqliteSchemaMigrationError(`open Store schema head ${this.#openedSchemaHead.version}/${this.#openedSchemaHead.checksum} `
|
|
228
|
+
+ `changed to ${String(current?.version)}/${String(current?.checksum)}; `
|
|
229
|
+
+ "reopen the Store with the active Yui version before writing", "admission");
|
|
230
|
+
}
|
|
218
231
|
}
|
|
219
232
|
#bumpRevision() {
|
|
220
233
|
this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
|
|
@@ -230,9 +243,12 @@ export class SqliteTaskStore {
|
|
|
230
243
|
this.#dirty = true;
|
|
231
244
|
return result;
|
|
232
245
|
}
|
|
233
|
-
this.#prepareWrite();
|
|
234
246
|
this.#begin();
|
|
235
247
|
try {
|
|
248
|
+
// Acquire SQLite's write reservation before checking the upgrade fence.
|
|
249
|
+
// A writer that started first commits before the upgrader's final
|
|
250
|
+
// transactional inventory; a writer that starts later sees the fence.
|
|
251
|
+
this.#prepareWrite();
|
|
236
252
|
const result = fn();
|
|
237
253
|
if (!this.#migration) {
|
|
238
254
|
this.#bumpRevision();
|
|
@@ -251,9 +267,12 @@ export class SqliteTaskStore {
|
|
|
251
267
|
return run(this);
|
|
252
268
|
this.#begin();
|
|
253
269
|
try {
|
|
270
|
+
// Pin the Store's validated schema generation before user code runs.
|
|
271
|
+
// This prevents a long-lived pre-upgrade Store from executing old SQL
|
|
272
|
+
// after an in-place migration has committed.
|
|
273
|
+
this.#prepareWrite();
|
|
254
274
|
const result = run(this);
|
|
255
275
|
if (this.#dirty) {
|
|
256
|
-
this.#prepareWrite();
|
|
257
276
|
if (options?.requestId !== undefined) {
|
|
258
277
|
this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
|
|
259
278
|
}
|
|
@@ -278,9 +297,9 @@ export class SqliteTaskStore {
|
|
|
278
297
|
async transactionAsync(execute) {
|
|
279
298
|
if (this.#inTransaction)
|
|
280
299
|
return execute(this);
|
|
281
|
-
this.#prepareWrite();
|
|
282
300
|
this.#begin();
|
|
283
301
|
try {
|
|
302
|
+
this.#prepareWrite();
|
|
284
303
|
const result = await execute(this);
|
|
285
304
|
if (this.#dirty)
|
|
286
305
|
this.#bumpRevision();
|
|
@@ -30,6 +30,7 @@ import { classifyStorage } from "../migration/index.js";
|
|
|
30
30
|
import { inspectStorageSchema } from "../storageSchema.js";
|
|
31
31
|
import { FileTaskStore, STORAGE_STATE_FILE, StorageRecordError } from "../taskStore.js";
|
|
32
32
|
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
33
|
+
import { inspectSqliteSchemaMigrations } from "../sqliteSchema.js";
|
|
33
34
|
import { inspectSourceVersionState } from "./homeMigrationTarget.js";
|
|
34
35
|
import { readMigrationReceipt } from "./migrationReceipt.js";
|
|
35
36
|
/**
|
|
@@ -111,6 +112,44 @@ export function classifyHome(options) {
|
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
}
|
|
115
|
+
// Once the logical Home axes are current, the SQLite ledger is the only
|
|
116
|
+
// remaining upgrade coordinate. A valid applied prefix with pending entries
|
|
117
|
+
// is upgradeable in place, not corruption and not a reason to rebuild the
|
|
118
|
+
// database. Ordinary store opens still validate-and-refuse this state; only
|
|
119
|
+
// the explicit updater is allowed to apply it.
|
|
120
|
+
if (schema.status === "current"
|
|
121
|
+
&& schema.currentLayoutVersion >= 7
|
|
122
|
+
&& isFullyCurrent(source, latest)) {
|
|
123
|
+
let sqliteMigration;
|
|
124
|
+
try {
|
|
125
|
+
sqliteMigration = inspectLayout7SqliteMigrations(home);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
classification: {
|
|
131
|
+
verdict: "CORRUPTED",
|
|
132
|
+
status: "unsupported",
|
|
133
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
134
|
+
},
|
|
135
|
+
layoutVersion: schema.currentLayoutVersion,
|
|
136
|
+
aggregateVersion: schema.currentAggregateSchemaVersion
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (sqliteMigration.pendingVersions.length > 0) {
|
|
140
|
+
return {
|
|
141
|
+
...base,
|
|
142
|
+
classification: {
|
|
143
|
+
verdict: "MIGRATABLE",
|
|
144
|
+
status: "migration-required",
|
|
145
|
+
stepCount: sqliteMigration.pendingVersions.length
|
|
146
|
+
},
|
|
147
|
+
layoutVersion: schema.currentLayoutVersion,
|
|
148
|
+
aggregateVersion: schema.currentAggregateSchemaVersion,
|
|
149
|
+
sqliteMigration
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
114
153
|
// The reference graph can only be validated by the strict loader, which only
|
|
115
154
|
// understands the current versions. So run it exactly when every axis is
|
|
116
155
|
// already current (the plan would be a no-op); a throw there is genuine
|
|
@@ -136,6 +175,19 @@ export function classifyHome(options) {
|
|
|
136
175
|
: { incompatibleComponent: incompatibleComponentOf(schema) })
|
|
137
176
|
};
|
|
138
177
|
}
|
|
178
|
+
function inspectLayout7SqliteMigrations(home) {
|
|
179
|
+
const db = new Database(join(home, "yui.db"), {
|
|
180
|
+
readonly: true,
|
|
181
|
+
fileMustExist: true
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
db.pragma("query_only = ON");
|
|
185
|
+
return inspectSqliteSchemaMigrations(db);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
db.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
139
191
|
/**
|
|
140
192
|
* Load a Home whose every axis is already current through the strict store
|
|
141
193
|
* gate to detect real structural/reference corruption. This is only ever
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
3
4
|
import { scanControllerResourceInventory } from "../../controller/resourceInventoryLinux.js";
|
|
4
5
|
import { STORAGE_STATE_FILE } from "../taskStore.js";
|
|
6
|
+
import { SQLITE_LAYOUT_VERSION } from "../sqliteSchema.js";
|
|
7
|
+
import { inspectStorageSchema } from "../storageSchema.js";
|
|
5
8
|
/** Pure blocker policy shared by synthetic tests and the real read-only scan. */
|
|
6
9
|
export function classifyOfflineUpgradeFacts(facts) {
|
|
7
10
|
const blockers = [];
|
|
@@ -45,14 +48,13 @@ export async function inspectOfflineUpgradeInventory(home, environment = process
|
|
|
45
48
|
const raw = readRawFacts(home, inventory);
|
|
46
49
|
return classifyOfflineUpgradeFacts(raw);
|
|
47
50
|
}
|
|
48
|
-
function readRawFacts(home, inventory) {
|
|
51
|
+
function readRawFacts(home, inventory, state = readDurableStateObject(home), conservativeDurableSessions = false) {
|
|
49
52
|
const runs = [];
|
|
50
53
|
const sessions = [];
|
|
51
54
|
const inFlight = [];
|
|
52
55
|
const pendingCompletions = [];
|
|
53
56
|
const lifecycle = [];
|
|
54
57
|
const unknownRuntime = [];
|
|
55
|
-
const state = readStateObject(home);
|
|
56
58
|
// Failure to enumerate processes, panes, or sockets means absence cannot be
|
|
57
59
|
// proven. Ownership-load warnings are expected for an old record shape and
|
|
58
60
|
// are handled by the raw durable scan plus unowned live-pane check below.
|
|
@@ -61,6 +63,8 @@ function readRawFacts(home, inventory) {
|
|
|
61
63
|
}
|
|
62
64
|
for (const [taskId, aggregateValue] of Object.entries(record(state.tasks))) {
|
|
63
65
|
const aggregate = record(aggregateValue);
|
|
66
|
+
if (text(record(aggregate.task).status) === "retired")
|
|
67
|
+
continue;
|
|
64
68
|
for (const runValue of Object.values(record(aggregate.agentRuns))) {
|
|
65
69
|
const run = record(runValue);
|
|
66
70
|
const roleName = text(run.roleName);
|
|
@@ -75,6 +79,7 @@ function readRawFacts(home, inventory) {
|
|
|
75
79
|
roleName,
|
|
76
80
|
set: record(setValue),
|
|
77
81
|
inventory,
|
|
82
|
+
conservativeDurableSessions,
|
|
78
83
|
sessions,
|
|
79
84
|
inFlight,
|
|
80
85
|
pendingCompletions
|
|
@@ -86,6 +91,7 @@ function readRawFacts(home, inventory) {
|
|
|
86
91
|
roleName,
|
|
87
92
|
set: record(setValue),
|
|
88
93
|
inventory,
|
|
94
|
+
conservativeDurableSessions,
|
|
89
95
|
sessions,
|
|
90
96
|
inFlight,
|
|
91
97
|
pendingCompletions
|
|
@@ -97,7 +103,7 @@ function readRawFacts(home, inventory) {
|
|
|
97
103
|
const kind = text(target.kind);
|
|
98
104
|
if (kind !== "role-runtime" && kind !== "global-role-runtime")
|
|
99
105
|
continue;
|
|
100
|
-
if (mailbox
|
|
106
|
+
if (!mailboxHasRuntimeWork(mailbox))
|
|
101
107
|
continue;
|
|
102
108
|
lifecycle.push({
|
|
103
109
|
...(text(target.taskId) === undefined ? {} : { taskId: text(target.taskId) }),
|
|
@@ -170,7 +176,7 @@ function isUndeterminableNativeInventoryWarning(warning) {
|
|
|
170
176
|
|| warning.startsWith("Cannot inspect Unix sockets");
|
|
171
177
|
}
|
|
172
178
|
function collectSessionSet(options) {
|
|
173
|
-
const { taskId, roleName, set, inventory } = options;
|
|
179
|
+
const { taskId, roleName, set, inventory, conservativeDurableSessions } = options;
|
|
174
180
|
const currentSessions = Object.entries(record(set.sessions));
|
|
175
181
|
const activeAgentId = text(set.activeAgentId);
|
|
176
182
|
const historyValue = set.history;
|
|
@@ -199,7 +205,7 @@ function collectSessionSet(options) {
|
|
|
199
205
|
};
|
|
200
206
|
options.sessions.push({
|
|
201
207
|
...base,
|
|
202
|
-
processState: processState(inventory, base)
|
|
208
|
+
processState: processState(inventory, base, conservativeDurableSessions)
|
|
203
209
|
});
|
|
204
210
|
}
|
|
205
211
|
const flight = record(set.inFlight);
|
|
@@ -245,12 +251,22 @@ function activeCurrentSession(sessions, taskId, roleName) {
|
|
|
245
251
|
return current.find(({ active }) => active === true)
|
|
246
252
|
?? (current.length === 1 ? current[0] : undefined);
|
|
247
253
|
}
|
|
248
|
-
function processState(inventory, session) {
|
|
254
|
+
function processState(inventory, session, conservativeDurableSessions) {
|
|
249
255
|
const matching = inventory.resources.filter((resource) => (resource.kind === "agent-session" && resourceMatchesSession(resource, session)));
|
|
250
256
|
if (matching.some((resource) => resource.processes.length > 0))
|
|
251
257
|
return "live";
|
|
252
258
|
if (matching.some((resource) => resource.paneDead === false))
|
|
253
259
|
return "unknown";
|
|
260
|
+
// The final SQLite gate runs after BEGIN IMMEDIATE, so no older writer can
|
|
261
|
+
// commit another Session record behind it. Treat every current non-terminal
|
|
262
|
+
// durable Session as active even when the earlier native-process snapshot
|
|
263
|
+
// did not contain it; this closes the inventory-scan -> transaction race.
|
|
264
|
+
if (conservativeDurableSessions
|
|
265
|
+
&& !session.history
|
|
266
|
+
&& session.status !== "stopped"
|
|
267
|
+
&& session.status !== "broken") {
|
|
268
|
+
return "unknown";
|
|
269
|
+
}
|
|
254
270
|
return "stopped";
|
|
255
271
|
}
|
|
256
272
|
function resourceMatchesSession(resource, session) {
|
|
@@ -272,13 +288,135 @@ function resourceMatchesSession(resource, session) {
|
|
|
272
288
|
return false;
|
|
273
289
|
return true;
|
|
274
290
|
}
|
|
275
|
-
function
|
|
291
|
+
function readDurableStateObject(home) {
|
|
292
|
+
const databasePath = join(home, "yui.db");
|
|
293
|
+
const schema = inspectStorageSchema(home);
|
|
294
|
+
const sqliteIsAuthoritative = (schema.status === "current" || schema.status === "unsupported")
|
|
295
|
+
&& schema.currentLayoutVersion >= SQLITE_LAYOUT_VERSION;
|
|
296
|
+
if (sqliteIsAuthoritative && existsSync(databasePath)) {
|
|
297
|
+
return readSqliteStateObject(databasePath);
|
|
298
|
+
}
|
|
276
299
|
const path = join(home, STORAGE_STATE_FILE);
|
|
277
300
|
if (!existsSync(path))
|
|
278
301
|
return {};
|
|
279
302
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
280
303
|
return record(parsed);
|
|
281
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Read only the durable runtime families needed by the offline gate. This raw
|
|
307
|
+
* adapter deliberately does not open SqliteTaskStore: a valid pending schema
|
|
308
|
+
* prefix is exactly the state the explicit upgrader must be able to inspect.
|
|
309
|
+
*/
|
|
310
|
+
function readSqliteStateObject(databasePath) {
|
|
311
|
+
const db = new Database(databasePath, { readonly: true, fileMustExist: true });
|
|
312
|
+
try {
|
|
313
|
+
db.pragma("query_only = ON");
|
|
314
|
+
return readSqliteStateObjectFromDatabase(db);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
db.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Re-check the authoritative SQLite runtime families on an already-open
|
|
322
|
+
* connection. The in-place upgrader calls this inside its write transaction,
|
|
323
|
+
* after every older writer has either committed or rolled back.
|
|
324
|
+
*/
|
|
325
|
+
export function inspectSqliteDurableUpgradeInventory(home, db) {
|
|
326
|
+
return classifyOfflineUpgradeFacts(readRawFacts(home, { resources: [], warnings: [] }, readSqliteStateObjectFromDatabase(db), true));
|
|
327
|
+
}
|
|
328
|
+
function readSqliteStateObjectFromDatabase(db) {
|
|
329
|
+
const taskRows = db.prepare("SELECT task_id, status FROM tasks_catalog").all();
|
|
330
|
+
// Explicit retirement is the only isolation boundary for anomalous
|
|
331
|
+
// historical runtime state. Completed/archived/draft Tasks still fail
|
|
332
|
+
// closed if they retain an active Run or unfinished lifecycle record.
|
|
333
|
+
const retiredTaskIds = new Set(taskRows.filter(({ status }) => status === "retired").map(({ task_id }) => task_id));
|
|
334
|
+
const tasks = Object.fromEntries(taskRows
|
|
335
|
+
.filter(({ task_id }) => !retiredTaskIds.has(task_id))
|
|
336
|
+
.map(({ task_id, status }) => [task_id, {
|
|
337
|
+
task: { status },
|
|
338
|
+
agentRuns: {},
|
|
339
|
+
roleSessionSets: {}
|
|
340
|
+
}]));
|
|
341
|
+
const taskAggregate = (taskId) => {
|
|
342
|
+
const existing = tasks[taskId];
|
|
343
|
+
if (existing !== undefined)
|
|
344
|
+
return existing;
|
|
345
|
+
const created = { task: {}, agentRuns: {}, roleSessionSets: {} };
|
|
346
|
+
tasks[taskId] = created;
|
|
347
|
+
return created;
|
|
348
|
+
};
|
|
349
|
+
for (const row of db.prepare("SELECT task_id, run_id, role_name, status, payload FROM agent_runs").all()) {
|
|
350
|
+
if (retiredTaskIds.has(row.task_id))
|
|
351
|
+
continue;
|
|
352
|
+
const aggregate = taskAggregate(row.task_id);
|
|
353
|
+
record(aggregate.agentRuns)[row.run_id] = {
|
|
354
|
+
...parseJsonRecord(row.payload, "agent_runs.payload"),
|
|
355
|
+
id: row.run_id,
|
|
356
|
+
roleName: row.role_name,
|
|
357
|
+
status: row.status
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
for (const row of db.prepare("SELECT task_id, role_name, payload FROM role_session_sets").all()) {
|
|
361
|
+
if (retiredTaskIds.has(row.task_id))
|
|
362
|
+
continue;
|
|
363
|
+
const aggregate = taskAggregate(row.task_id);
|
|
364
|
+
record(aggregate.roleSessionSets)[row.role_name] = parseJsonRecord(row.payload, "role_session_sets.payload");
|
|
365
|
+
}
|
|
366
|
+
const globalRoleSessionSets = {};
|
|
367
|
+
for (const row of db.prepare("SELECT name, payload FROM global_role_session_sets").all()) {
|
|
368
|
+
globalRoleSessionSets[row.name] = parseJsonRecord(row.payload, "global_role_session_sets.payload");
|
|
369
|
+
}
|
|
370
|
+
const mailboxColumns = new Set(db.pragma("table_info(mailboxes)").map(({ name }) => name));
|
|
371
|
+
const hasInputDelivery = mailboxColumns.has("input_delivery");
|
|
372
|
+
const mailboxes = {};
|
|
373
|
+
for (const row of db.prepare(`SELECT target_key, target_kind, task_id, role_name, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes`).all()) {
|
|
374
|
+
if (row.task_id !== null && retiredTaskIds.has(row.task_id))
|
|
375
|
+
continue;
|
|
376
|
+
mailboxes[row.target_key] = {
|
|
377
|
+
target: {
|
|
378
|
+
kind: row.target_kind,
|
|
379
|
+
...(row.task_id === null ? {} : { taskId: row.task_id }),
|
|
380
|
+
...(row.role_name === null ? {} : { roleName: row.role_name })
|
|
381
|
+
},
|
|
382
|
+
processing: parseNullableJson(row.processing, "mailboxes.processing"),
|
|
383
|
+
pending: parseNullableJson(row.pending, "mailboxes.pending"),
|
|
384
|
+
inputDelivery: parseNullableJson(row.input_delivery ?? null, "mailboxes.input_delivery")
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { tasks, globalRoleSessionSets, mailboxes };
|
|
388
|
+
}
|
|
389
|
+
function mailboxHasRuntimeWork(mailbox) {
|
|
390
|
+
if (mailbox.processing !== null && mailbox.processing !== undefined)
|
|
391
|
+
return true;
|
|
392
|
+
if (mailbox.inputDelivery !== null && mailbox.inputDelivery !== undefined)
|
|
393
|
+
return true;
|
|
394
|
+
if (mailbox.pending === null || mailbox.pending === undefined)
|
|
395
|
+
return false;
|
|
396
|
+
const pending = record(mailbox.pending);
|
|
397
|
+
if (Object.hasOwn(pending, "normal") || Object.hasOwn(pending, "userCorrection")) {
|
|
398
|
+
return pending.normal !== null || pending.userCorrection !== null;
|
|
399
|
+
}
|
|
400
|
+
// Pre-v14 mailboxes store one pending batch directly.
|
|
401
|
+
return Object.keys(pending).length > 0;
|
|
402
|
+
}
|
|
403
|
+
function parseJsonRecord(raw, label) {
|
|
404
|
+
const parsed = JSON.parse(raw);
|
|
405
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
406
|
+
throw new Error(`${label} is not a JSON object.`);
|
|
407
|
+
}
|
|
408
|
+
return parsed;
|
|
409
|
+
}
|
|
410
|
+
function parseNullableJson(raw, label) {
|
|
411
|
+
if (raw === null)
|
|
412
|
+
return null;
|
|
413
|
+
try {
|
|
414
|
+
return JSON.parse(raw);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
throw new Error(`${label} is not valid JSON.`, { cause: error });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
282
420
|
function countPendingInbox(home) {
|
|
283
421
|
return [join(home, "runtime", "inbox"), join(home, "runtime", "inbox-invalid")]
|
|
284
422
|
.reduce((total, directory) => total + countDirectory(directory), 0);
|