@zq-silk/yui 2.6.0 → 2.7.1

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.
Files changed (72) hide show
  1. package/README.md +23 -0
  2. package/dist/cli/commandCatalog.js +28 -2
  3. package/dist/commands/globalRoleCommands.js +22 -2
  4. package/dist/commands/inboxCommands.js +352 -0
  5. package/dist/commands/roleSkillValidation.js +2 -0
  6. package/dist/commands/taskCommands.js +32 -4
  7. package/dist/context/currentSkills.js +48 -0
  8. package/dist/context/roleSessionContext.js +13 -16
  9. package/dist/context/runContextPack.js +2 -0
  10. package/dist/context/runInputContract.js +1 -1
  11. package/dist/context/sessionBootstrapManifest.js +27 -4
  12. package/dist/context/skillCatalog.js +2 -3
  13. package/dist/context/taskContext.js +2 -0
  14. package/dist/controlPlaneCli.js +18 -1
  15. package/dist/controller/controller.js +3 -2
  16. package/dist/controller/fileSchedulerStoreAdapter.js +19 -4
  17. package/dist/controller/globalInputDelivery.js +73 -15
  18. package/dist/controller/globalRuntimeAttention.js +1 -1
  19. package/dist/controller/operatorNotification.js +6 -1
  20. package/dist/controller/providerRetryDelivery.js +3 -5
  21. package/dist/controller/runtime.js +2 -2
  22. package/dist/doctor/doctor.js +25 -2
  23. package/dist/executor/effectiveLaunch.js +2 -2
  24. package/dist/executor/fileRoleLaunchPlanner.js +4 -6
  25. package/dist/interaction/notificationPresentation.js +2 -2
  26. package/dist/interaction/operatorPresentation.js +1 -1
  27. package/dist/message/globalInputBacklog.js +69 -0
  28. package/dist/message/globalMessageQuery.js +1 -0
  29. package/dist/message/inboxReceipt.js +32 -0
  30. package/dist/message/message.js +6 -1
  31. package/dist/message/messageContinuation.js +34 -13
  32. package/dist/message/taskInboxOwnership.js +21 -0
  33. package/dist/nativeAgent/session/backends.js +1 -1
  34. package/dist/observability/runtimeIdentity.js +1 -1
  35. package/dist/resources/sqliteResourceRegistry.js +1 -1
  36. package/dist/runtime/agentHost.js +105 -16
  37. package/dist/scheduler/operatorEvent.js +49 -0
  38. package/dist/scheduler/operatorInputNotificationProcessor.js +5 -4
  39. package/dist/scheduler/taskWake.js +5 -0
  40. package/dist/sqlite/driver.js +108 -0
  41. package/dist/storage/sqliteSchema.js +11 -1
  42. package/dist/storage/sqliteStore.js +60 -1
  43. package/dist/storage/storageSchema.js +1 -1
  44. package/dist/storage/storageVersions.js +1 -1
  45. package/dist/storage/storeRpc.js +1 -0
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +1 -1
  47. package/dist/task/completionReadiness.js +11 -4
  48. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  49. package/docs/managed-turn-and-session-runtime.md +81 -5
  50. package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -3
  51. package/docs/release-workflow.md +47 -2
  52. package/docs/release-workflow.zh-CN.md +34 -1
  53. package/docs/storage-baseline.md +17 -7
  54. package/docs/storage-baseline.zh-CN.md +11 -5
  55. package/docs/task-delivery.md +17 -8
  56. package/docs/task-delivery.zh-CN.md +11 -5
  57. package/docs/testing/verification-levels.md +22 -4
  58. package/docs/testing/verification-levels.zh-CN.md +8 -1
  59. package/i18n/README.zh-CN.md +19 -0
  60. package/package.json +5 -2
  61. package/skills/yui-leader/SKILL.md +28 -11
  62. package/skills/yui-leader/VERSION +1 -0
  63. package/skills/yui-operator/SKILL.md +10 -2
  64. package/skills/yui-operator/VERSION +1 -0
  65. package/skills/yui-operator/references/task-delivery.md +2 -0
  66. package/skills/yui-reviewer/SKILL.md +8 -1
  67. package/skills/yui-reviewer/VERSION +1 -0
  68. package/skills/yui-runtime/SKILL.md +94 -11
  69. package/skills/yui-runtime/VERSION +1 -0
  70. package/skills/yui-runtime/references/publication.md +16 -5
  71. package/skills/yui-worker/SKILL.md +8 -1
  72. package/skills/yui-worker/VERSION +1 -0
@@ -1,6 +1,55 @@
1
1
  import { enqueueWork } from "../coordination/workMailboxQueue.js";
2
2
  import { createTaskEvent } from "../event/taskEvent.js";
3
+ import { claimPending } from "../coordination/workMailbox.js";
3
4
  export const LEADER_ATTENTION_REQUIRED_EVENT = "leader.attention-required";
5
+ /** Bound each Operator pass, retaining the exact unclaimed sequence suffix. */
6
+ export function claimOperatorAttention(mailbox, options) {
7
+ const pending = mailbox.pending;
8
+ if (pending === null || pending.requestCount <= 64)
9
+ return claimPending(mailbox, options);
10
+ // Operator producers append one unique source ref per signal. Mailbox merge
11
+ // preserves insertion order for refs and keys, including the supported
12
+ // default sequence keys used by Provider retry attention. Preserve those
13
+ // keys verbatim; they are valid current data, not a migration or repair.
14
+ if (pending.requestCount !== pending.dedupeKeys.length
15
+ || pending.refs.length !== pending.requestCount
16
+ || pending.toSequence - pending.fromSequence + 1 !== pending.requestCount
17
+ || pending.dedupeKeys.some((key, index) => {
18
+ const ref = pending.refs[index];
19
+ return (ref.type !== "event" && ref.type !== "input")
20
+ || key !== `operator-${ref.type}:${ref.taskId}:${ref.id}`
21
+ && key !== `sequence:${pending.fromSequence + index}`;
22
+ })) {
23
+ throw new Error("Operator attention batch cannot be safely partitioned; inspect its source refs and sequence before retrying.");
24
+ }
25
+ const prefixKeys = pending.dedupeKeys.slice(0, 64);
26
+ const suffixKeys = pending.dedupeKeys.slice(64);
27
+ const through = pending.fromSequence + 63;
28
+ const prefix = { ...pending, toSequence: through, requestCount: 64,
29
+ dedupeKeys: prefixKeys, refs: pending.refs.slice(0, 64) };
30
+ const claimed = claimPending({ ...mailbox, pending: prefix }, {
31
+ ...options, batchId: `operator:${prefix.fromSequence}-${prefix.toSequence}`
32
+ });
33
+ return { ...claimed, pending: { ...pending, fromSequence: through + 1,
34
+ requestCount: pending.requestCount - 64, dedupeKeys: suffixKeys,
35
+ refs: pending.refs.slice(64) } };
36
+ }
37
+ /**
38
+ * Fixed source admission at the durable event write, in the same transaction.
39
+ * The existing mailbox is the incremental restart cursor: periodic scheduling
40
+ * reads only outstanding refs, never re-scans or replays historical events.
41
+ * Context, idle observations and ordinary progress are deliberately absent.
42
+ */
43
+ export function enqueueDurableOperatorFact(store, event) {
44
+ const now = new Date(event.createdAt);
45
+ if (event.type === "input.requested" && typeof event.payload.requestId === "string") {
46
+ enqueueWork(store, { kind: "operator" }, "input-requested", now, [{ type: "input", taskId: event.taskId, id: event.payload.requestId }], { source: "input-request", dedupeKey: `operator-input:${event.taskId}:${event.payload.requestId}` });
47
+ }
48
+ else if (["task.completed", "task.cancelled", "task.retired", "publication.recorded",
49
+ "publication.candidate-adopted", LEADER_ATTENTION_REQUIRED_EVENT].includes(event.type)) {
50
+ enqueueOperatorEvent(store, event, event.type.startsWith("task.") ? "task-terminal" : event.type, now);
51
+ }
52
+ }
4
53
  /** One event, one supervisor signal; the Leader wake is this mailbox's projection. */
5
54
  export function enqueueSupervisorEvent(store, event, recipient, reason, now) {
6
55
  if (store.getTask(event.taskId)?.status === "archived")
@@ -28,8 +28,9 @@ export async function processOperatorInputNotifications(store, selection, now =
28
28
  }
29
29
  if (ref.type !== "event")
30
30
  return [];
31
- const event = store.listEvents(ref.taskId).find(({ id }) => id === ref.id);
32
- return event === undefined ? [] : [{ kind: "event", event }];
31
+ const event = store.getEvent !== undefined ? store.getEvent(ref.taskId, ref.id)
32
+ : store.listEvents(ref.taskId).find(({ id }) => id === ref.id);
33
+ return event == null ? [] : [{ kind: "event", event }];
33
34
  }));
34
35
  if (attentions.length === 0) {
35
36
  store.completeWorkMailbox(targetMailbox, processing.batchId);
@@ -42,9 +43,9 @@ export async function processOperatorInputNotifications(store, selection, now =
42
43
  receiptId: presentation.receiptId,
43
44
  text: presentation.text
44
45
  }, now);
45
- if (outcome.status === "unavailable") {
46
+ if (outcome.status === "unavailable" || outcome.status === "deferred") {
46
47
  store.releaseWorkMailbox(targetMailbox, processing.batchId);
47
- return attentions.map((attention) => skipped(attention, "operator-unavailable"));
48
+ return attentions.map((attention) => skipped(attention, outcome.status === "deferred" ? "notice-pending" : "operator-unavailable"));
48
49
  }
49
50
  return attentions.map((attention) => ({
50
51
  ...attentionIdentity(attention),
@@ -1,6 +1,7 @@
1
1
  import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
2
2
  import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
3
3
  import { copyRef } from "../coordination/workMailbox.js";
4
+ import { validateInboxReceipt } from "../message/inboxReceipt.js";
4
5
  export const CURRENT_TASK_WAKE_SCHEMA_VERSION = 1;
5
6
  export function createTaskWake(input) {
6
7
  if (Object.hasOwn(input, "runId"))
@@ -21,6 +22,10 @@ export function createTaskWake(input) {
21
22
  return wake;
22
23
  }
23
24
  export function validateTaskWake(wake) {
25
+ validateInboxReceipt(wake.inbox);
26
+ if (wake.inboxPendingId !== undefined && (!/^pending-[a-f0-9]{64}$/.test(wake.inboxPendingId) || !wake.inbox)) {
27
+ throw new Error("An active inbox wake requires its exact pending identity and handling claim.");
28
+ }
24
29
  if (Object.hasOwn(wake, "runId"))
25
30
  throw new Error("Run-backed wakes are not current notification records.");
26
31
  if (wake.refs !== undefined) {
@@ -0,0 +1,108 @@
1
+ import { createRequire } from "node:module";
2
+ export const SQLITE_CANDIDATES = Object.freeze([
3
+ // N-API avoids node::ObjectWrap's GC cleanup abort on Node 24.19/24.20.
4
+ Object.freeze({ name: "sqlite-modern", version: "13.0.3", minimumNapi: 10 }),
5
+ Object.freeze({ name: "sqlite-compat", version: "10.1.0", minimumNapi: 0 })
6
+ ]);
7
+ /** Only errors attributable to loading this native candidate, never database errors. */
8
+ function nativeUnavailable(error, candidate) {
9
+ if (!(error instanceof Error))
10
+ return false;
11
+ const code = error.code;
12
+ const firstLine = error.message.split("\n")[0];
13
+ if (code === "MODULE_NOT_FOUND") {
14
+ if (firstLine === `Cannot find module '${candidate.name}'`)
15
+ return true;
16
+ // v13 loads its bundled prebuild or these node-gyp paths directly.
17
+ // Do not swallow an unrelated missing transitive JS dependency.
18
+ const missing = /^Cannot find module '([^']+)'$/.exec(firstLine)?.[1];
19
+ return missing !== undefined && ["Debug", "Release"].some((mode) => missing.endsWith(`/node_modules/${candidate.name}/build/${mode}/better_sqlite3.node`));
20
+ }
21
+ const tries = error.tries;
22
+ if (error.message.startsWith("Could not locate the bindings file.") && Array.isArray(tries)) {
23
+ return tries.length > 0 && tries.every((path) => typeof path === "string" && path.includes(`/node_modules/${candidate.name}/`)
24
+ && path.endsWith("/better_sqlite3.node"));
25
+ }
26
+ if (code !== "ERR_DLOPEN_FAILED")
27
+ return false;
28
+ return /NODE_MODULE_VERSION \d+/.test(error.message)
29
+ || /(?:GLIBC|GLIBCXX|CXXABI)_[\d.]+['’] not found/.test(error.message)
30
+ || /(?:cannot open shared object file|Library not loaded:|wrong ELF class:|invalid ELF header|incompatible architecture)/.test(error.message);
31
+ }
32
+ const require = createRequire(import.meta.url);
33
+ function selectionFailure(failures) {
34
+ return new AggregateError(failures, "No usable SQLite driver. " + SQLITE_CANDIDATES.map((candidate, index) => `${candidate.name}@${candidate.version}: ${failures[index] instanceof Error ? failures[index].message : String(failures[index])}`).join("; ")
35
+ + ". Reinstall with npm optional dependencies enabled (do not use --omit=optional); "
36
+ + "check Node ABI, system libraries and native build tools. Older systems may require an OS/toolchain upgrade.");
37
+ }
38
+ /**
39
+ * Selection is isolated from every real database open. No real Home operation
40
+ * is inside a fallback catch, and successful selection stays fixed per process.
41
+ * Loader injection supports deterministic native-failure tests, not user config.
42
+ */
43
+ export function createSqliteDriverSelector(load = (candidate) => require(candidate.name), napiVersion = Number(process.versions.napi ?? 0)) {
44
+ let selected;
45
+ return () => {
46
+ if (selected)
47
+ return selected;
48
+ const failures = [];
49
+ for (const candidate of SQLITE_CANDIDATES) {
50
+ // An unsupported Node-API constructor can crash outside JS exception
51
+ // handling. Exclude it before loading native code, not after probing.
52
+ if (napiVersion < candidate.minimumNapi) {
53
+ failures.push(new Error(`${candidate.name} requires Node-API ${candidate.minimumNapi}; runtime provides ${napiVersion}`));
54
+ continue;
55
+ }
56
+ let Driver;
57
+ let probe;
58
+ try {
59
+ Driver = load(candidate);
60
+ probe = new Driver(":memory:");
61
+ }
62
+ catch (error) {
63
+ if (!nativeUnavailable(error, candidate)) {
64
+ // Stop on unknown errors, but retain the modern failure when the
65
+ // compatibility candidate also fails. This does not permit retry.
66
+ if (failures.length)
67
+ throw selectionFailure([...failures, error]);
68
+ throw error;
69
+ }
70
+ failures.push(error);
71
+ continue;
72
+ }
73
+ // SQL and close failures are deliberately outside the native-load catch.
74
+ let sqliteVersion;
75
+ try {
76
+ try {
77
+ const result = probe.prepare("SELECT sqlite_version() AS version, 1 AS value").get();
78
+ if (typeof result?.version !== "string" || result.value !== 1) {
79
+ throw new Error(`SQLite probe returned an invalid result for ${candidate.name}.`);
80
+ }
81
+ sqliteVersion = result.version;
82
+ }
83
+ finally {
84
+ probe.close();
85
+ }
86
+ }
87
+ catch (error) {
88
+ if (failures.length)
89
+ throw selectionFailure([...failures, error]);
90
+ throw error;
91
+ }
92
+ selected = Object.freeze({
93
+ ...candidate, Database: Driver, sqliteVersion,
94
+ ...(failures.length ? { fallbackReason: failures[0].message } : {})
95
+ });
96
+ return selected;
97
+ }
98
+ throw selectionFailure(failures);
99
+ };
100
+ }
101
+ export const selectSqliteDriver = createSqliteDriverSelector();
102
+ // Preserve the existing constructor/type contract without loading native code
103
+ // during module import. All runtime consumers share the same selector.
104
+ const Database = function (filename, options) {
105
+ const Driver = selectSqliteDriver().Database;
106
+ return new Driver(filename, options);
107
+ };
108
+ export default Database;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import Database from "better-sqlite3";
2
+ import Database from "../sqlite/driver.js";
3
3
  import { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES } from "./baselineSchema.js";
4
4
  import { migrateTaskMainWorkspaces } from "./upgrade/taskMainWorkspaceMigration.js";
5
5
  import { migrateProjectKnowledge } from "./upgrade/projectKnowledgeMigration.js";
@@ -76,6 +76,16 @@ const MINOR_UPGRADES = Object.freeze([{
76
76
  targetChecksum: CURRENT_SCHEMA_CHECKSUM,
77
77
  // Optional sourced monetary observations; historical missing costs stay unknown.
78
78
  sql: ""
79
+ }, {
80
+ fromVersion: "1.6",
81
+ toVersion: "1.7",
82
+ name: "agent-inbox-consumption",
83
+ introducedIn: "next",
84
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
85
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
86
+ // Additive handling receipts on canonical Messages and wakes. Never invent
87
+ // historical processing or Provider acceptance; existing records stay intact.
88
+ sql: ""
79
89
  }]);
80
90
  export function storageMinorUpgradePlan(from) {
81
91
  if (from === CURRENT_STORAGE_VERSION)
@@ -32,7 +32,7 @@
32
32
  * including writes, reads and explicit Home diagnostics. There is no weaker
33
33
  * in-process contract or deferred validation phase.
34
34
  */
35
- import Database from "better-sqlite3";
35
+ import Database from "../sqlite/driver.js";
36
36
  import { existsSync, mkdirSync } from "node:fs";
37
37
  import { join } from "node:path";
38
38
  import { isDeepStrictEqual } from "node:util";
@@ -40,8 +40,10 @@ import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
40
40
  import { validateTaskBrief } from "../brief/taskBrief.js";
41
41
  import { validateContextSnapshot } from "../context/contextSnapshot.js";
42
42
  import { consumePendingBatch, mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
43
+ import { enqueueDurableOperatorFact } from "../scheduler/operatorEvent.js";
43
44
  import { validateDurableJob, validDurableJobTransition } from "../job/durableJob.js";
44
45
  import { validateGlobalRoleMessage } from "../message/message.js";
46
+ import { assertInboxReceiptPreserved } from "../message/inboxReceipt.js";
45
47
  import { validatePluginIntent, validatePluginIntentFailure } from "../plugins/pluginIntent.js";
46
48
  import { validatePluginValidation } from "../plugins/pluginPackage.js";
47
49
  import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeIdentity.js";
@@ -975,10 +977,58 @@ export class SqliteTaskStore {
975
977
  listGlobalRoleMessages(roleName) {
976
978
  return this.#sortById(this.#listPayload("global_role_messages", "name = ?", [roleName]), (message) => message.id);
977
979
  }
980
+ queryGlobalRoleMessages(roleName, query) {
981
+ if (!Number.isSafeInteger(query.limit) || query.limit < 0 || query.limit > 64
982
+ || !Number.isSafeInteger(query.afterSequence ?? 0) || (query.afterSequence ?? 0) < 0) {
983
+ throw new Error("Global Message query exceeds its bounded contract.");
984
+ }
985
+ const where = ["name = @name"];
986
+ const params = { name: roleName };
987
+ if (query.handling)
988
+ where.push(`json_extract(payload, '$.inbox.claim') IS NOT NULL
989
+ AND json_extract(payload, '$.inbox.ack') IS NULL`);
990
+ if (query.pending)
991
+ where.push(`json_extract(payload, '$.delivery') IS NULL
992
+ AND json_extract(payload, '$.inbox.ack') IS NULL
993
+ AND json_extract(payload, '$.notDelivered') IS NULL
994
+ AND (json_extract(payload, '$.inputControl.action') = 'queue'
995
+ OR json_extract(payload, '$.interruptThen') IS NOT NULL)`);
996
+ if (query.interruptThen)
997
+ where.push("json_extract(payload, '$.interruptThen') IS NOT NULL");
998
+ if (query.operatorNotice)
999
+ where.push("json_extract(payload, '$.inputControl.requestId') LIKE 'operator-notice:%'");
1000
+ if (query.afterSequence !== undefined) {
1001
+ where.push("seq > @after");
1002
+ params.after = query.afterSequence;
1003
+ }
1004
+ if (query.id !== undefined) {
1005
+ where.push("message_id = @id");
1006
+ params.id = query.id;
1007
+ }
1008
+ if (query.requestId !== undefined) {
1009
+ where.push("COALESCE(json_extract(payload, '$.inputControl.requestId'), json_extract(payload, '$.interruptThen.reusedInput.requestId')) = @request");
1010
+ params.request = query.requestId;
1011
+ }
1012
+ const filter = where.join(" AND ");
1013
+ const summary = this.#db.prepare(`SELECT count(*) AS count, min(created_at) AS oldestCreatedAt
1014
+ FROM global_role_messages WHERE ${filter}`).get(params);
1015
+ const rows = this.#db.prepare(`SELECT payload FROM global_role_messages WHERE ${filter}
1016
+ ORDER BY seq ${query.descending ? "DESC" : "ASC"} LIMIT @limit`)
1017
+ .all({ ...params, limit: query.limit });
1018
+ return { ...summary, messages: rows.map(row => {
1019
+ const message = this.#parse(row.payload);
1020
+ validateGlobalRoleMessage(message);
1021
+ return message;
1022
+ }) };
1023
+ }
978
1024
  updateGlobalRoleMessage(message) {
979
1025
  validateGlobalRoleMessage(message);
980
1026
  this.#requireGlobalRole(message.roleName);
981
1027
  this.#mutate(() => {
1028
+ const existing = this.#db.prepare("SELECT payload FROM global_role_messages WHERE name = ? AND message_id = ?")
1029
+ .get(message.roleName, message.id);
1030
+ assertInboxReceiptPreserved(existing === undefined ? undefined
1031
+ : this.#parse(existing.payload).inbox, message.inbox);
982
1032
  const seq = this.#globalMessageSequence(message.id);
983
1033
  const result = this.#db.prepare(`UPDATE global_role_messages SET seq = ?, payload = ? WHERE name = ? AND message_id = ?`).run(seq, this.#recordJson("global_role_messages", message), message.roleName, message.id);
984
1034
  if (result.changes === 0) {
@@ -2001,6 +2051,10 @@ export class SqliteTaskStore {
2001
2051
  }
2002
2052
  this.#requireTask(taskId);
2003
2053
  this.#mutate(() => {
2054
+ const existing = this.#db.prepare("SELECT payload FROM messages WHERE task_id = ? AND message_id = ?")
2055
+ .get(taskId, message.id);
2056
+ assertInboxReceiptPreserved(existing === undefined ? undefined
2057
+ : this.#parse(existing.payload).inbox, message.inbox);
2004
2058
  const result = this.#db.prepare(`UPDATE messages SET payload = ? WHERE task_id = ? AND message_id = ?`).run(this.#recordJson("messages", message), taskId, message.id);
2005
2059
  if (result.changes !== 1) {
2006
2060
  throw new StorageRecordError(`Message does not exist: ${taskId}/${message.id}`);
@@ -2086,6 +2140,7 @@ export class SqliteTaskStore {
2086
2140
  // Events are terminal/semantic: retained individually, never pruned (§9).
2087
2141
  this.#db.prepare(`INSERT INTO events (task_id, event_id, type, occurred_at, payload) VALUES (?, ?, ?, ?, ?)`).run(taskId, event.id, event.type, event.createdAt, this.#recordJson("events", event));
2088
2142
  this.#observeHighWater(taskId, "event", seq);
2143
+ enqueueDurableOperatorFact(this, event);
2089
2144
  });
2090
2145
  }
2091
2146
  listEvents(taskId) {
@@ -2113,6 +2168,10 @@ export class SqliteTaskStore {
2113
2168
  validateTaskWake(wake);
2114
2169
  this.#requireTask(taskId);
2115
2170
  this.#mutate(() => {
2171
+ const existing = this.#db.prepare("SELECT payload FROM task_wakes WHERE task_id = ? AND wake_id = ?")
2172
+ .get(taskId, wake.id);
2173
+ assertInboxReceiptPreserved(existing === undefined ? undefined
2174
+ : this.#parse(existing.payload).inbox, wake.inbox);
2116
2175
  const seq = this.#idSequence(wake.id, "taskWake");
2117
2176
  this.#db.prepare(`INSERT INTO task_wakes
2118
2177
  (task_id, wake_id, seq, status, from_cursor, to_cursor, reasons, payload, created_at, consumed_at)
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import Database from "better-sqlite3";
3
+ import Database from "../sqlite/driver.js";
4
4
  import { inspectSqliteSchema, storageMinorUpgradePlan, SqliteSchemaError } from "./sqliteSchema.js";
5
5
  import { compareStorageVersions, CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "./storageVersions.js";
6
6
  export { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "./storageVersions.js";
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.6";
1
+ export const CURRENT_STORAGE_VERSION = "1.7";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -48,6 +48,7 @@ const READ_ONLY_STORE_METHODS = new Set([
48
48
  "getGlobalRoleSessionSet",
49
49
  "listGlobalRoleSessionSets",
50
50
  "listGlobalRoleMessages",
51
+ "queryGlobalRoleMessages",
51
52
  "listTasks",
52
53
  "getTask",
53
54
  "readNextActionFacts",
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { copyFileSync, existsSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
3
- import Database from "better-sqlite3";
3
+ import Database from "../../sqlite/driver.js";
4
4
  import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation } from "../../runtime/runtimeObservation.js";
5
5
  import { RUNTIME_PROCESS_EXIT_TASK_EVENT, validateRuntimeProcessExitObservation } from "../../runtime/processExitObservation.js";
6
6
  import { SqliteTaskStore } from "../sqliteStore.js";
@@ -1,12 +1,18 @@
1
1
  import { governingWorkItemDeliveries, integrationAttemptRequiresSettlement, workItemDeliverySettled } from "../integration/deliveryObligation.js";
2
+ import { isLeaderSteer, taskMessageInboxReceipt } from "../message/taskInboxOwnership.js";
2
3
  import { operationalTaskRecords } from "./taskRecordRetirement.js";
3
- /** Mailbox refs locate original durable user intent. No separate acknowledgement
4
- * state is introduced: finish the current native turn and let the existing
5
- * notification delivery present the new input before declaring completion. */
4
+ /** Mailbox refs locate original durable user intent. Active inbox handling
5
+ * retains the same completion fence until the exact window is acknowledged. */
6
6
  export function pendingCompletionMessages(store, taskId) {
7
7
  const mailbox = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" });
8
8
  const refs = [...(mailbox?.pending?.refs ?? [])];
9
+ for (const wake of store.listTaskWakes(taskId)) {
10
+ if (wake.inbox !== undefined && wake.inbox.ack === undefined)
11
+ refs.push(...(wake.refs ?? []));
12
+ }
9
13
  const processing = mailbox?.processing;
14
+ if (processing?.owner.startsWith("agent-inbox:"))
15
+ refs.push(...processing.batch.refs);
10
16
  if (processing?.owner.startsWith("leader-notification:")) {
11
17
  const native = store.getTaskRoleSessionSet(taskId, "leader")?.providerBinding?.run;
12
18
  if (native?.attemptId !== processing.batchId
@@ -16,7 +22,8 @@ export function pendingCompletionMessages(store, taskId) {
16
22
  }
17
23
  const ids = new Set(refs.filter(ref => ref.type === "message" && ref.taskId === taskId).map(ref => ref.id));
18
24
  return operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message")
19
- .filter(message => ids.has(message.id)
25
+ .filter(message => (ids.has(message.id) || isLeaderSteer(message) && message.inbox !== undefined)
26
+ && taskMessageInboxReceipt(store, message)?.ack === undefined
20
27
  && (message.kind === "user" || message.kind === "operator"));
21
28
  }
22
29
  const ACTIVE_JOB_STATUSES = new Set([
@@ -1,4 +1,4 @@
1
- import Database from "better-sqlite3";
1
+ import Database from "../sqlite/driver.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { CURRENT_DATABASE_FILENAME as COMMITTED_DATABASE_FILENAME } from "../storage/currentTaskStore.js";
@@ -69,11 +69,27 @@ Run Pack is a reference directory: read the relevant requirement and message
69
69
  bodies before acting, rather than treating a successful load as the deliverable.
70
70
  Planning Packs expose no Project write scope or Task-completion permission.
71
71
 
72
- The Manifest pointer is a recovery locator, not a read receipt. Load its exact
73
- `yui-runtime`, Role Skills and profile on first entry, a new/version-changed
74
- Session, or missing context after compaction/recovery. Reuse instructions only
75
- when the same-version full content is still available; Yui stores no "already
76
- read" state. Always refresh dynamic facts. Leader notifications use
72
+ The Manifest pointer is a recovery locator, not a read receipt. At the start of
73
+ every turn run `yui session skills --json` through the Session's CLI/Home. It
74
+ checks current paths, internal `VERSION` files and readability, without reading
75
+ Skill bodies or walking/snapshotting resources. Builtins use the running
76
+ installation's `skills/<id>` entry; configured Skills use `YUI_HOME/skills/<id>`.
77
+ These paths do not contain a Session/content digest. After changing installation
78
+ locations the check resolves the current installation, not an old recorded path.
79
+ For Global Sessions the Manifest's `skillCheckCommand` is its self-contained
80
+ `session context <role>` read, which also returns current Skill metadata and works
81
+ after moving to Desktop without the launch shell's environment. An older Global
82
+ Manifest's existing `loadCommand` provides the same check.
83
+
84
+ Load current `yui-runtime`, applicable Role/custom Skills and necessary references,
85
+ plus the Manifest's profile, on first entry, a version change, recovery, or missing/
86
+ compacted/uncertain context. Reuse only when versions are unchanged and the full
87
+ instructions remain available now. Yui stores no "already read" state and does
88
+ not claim to inspect the model's context. Historical packages remain audit only;
89
+ missing/corrupt snapshots do not prevent valid Session resume or input delivery.
90
+ Missing current files report their exact path and recovery action. Session
91
+ identity, scope, writer fences and real CLI/Home incompatibility still reject.
92
+ Always refresh dynamic facts. Leader notifications use
77
93
  `currentCommand`; dispatched AgentRuns use the actual Run ID with
78
94
  `loadCommand`/`expandCommand`, never their placeholder templates.
79
95
 
@@ -139,6 +155,66 @@ summary store, read-acknowledgement or extra model call is involved. Missing or
139
155
  oversized summaries fall back to facts without clipping qualifications.
140
156
  Summaries never grant authority, accept results or mark reviews approved.
141
157
 
158
+ ### Operator backlog and periodic recovery
159
+
160
+ Task completion/cancellation/retirement, publication records/adoption and input
161
+ requests enqueue Operator attention in the same transaction as their durable
162
+ event. Existing Run failure routing keeps its retry and supervisor guards.
163
+ The ready mailbox is the incremental recovery source: a lost scheduler signal
164
+ or Controller restart does not require a Leader report or a scan of historical
165
+ Tasks. The existing periodic pass retries outstanding mailbox work; an unchanged,
166
+ settled mailbox creates neither a Message nor a model call.
167
+
168
+ At most one unsent engineering notice is queued. Further facts coalesce in the
169
+ mailbox until that notice is accepted or conclusively not delivered. Each handoff
170
+ claims at most 64 source signals and reads exact records, retaining the unclaimed
171
+ suffix. Global delivery reads at most 64 pending Messages per Role per pass;
172
+ conclusively stale originals retain their nondelivery evidence, with later pages
173
+ handled by subsequent passes. Reserved interrupt-then inputs remain discoverable
174
+ beyond that ordinary page. FIFO user inputs and unknown-effect fences are not
175
+ reordered to make notifications appear healthy. SQL aggregates still inspect
176
+ matching stored rows; these bounds limit materialized records and per-pass
177
+ delivery work, not the size of the persisted mailbox or database.
178
+
179
+ A full reconciliation may ask the original Host to republish an already observed
180
+ terminal for the exact Session, authority, attempt and native Turn. For a Desktop
181
+ direct Turn, that Host must have retained both the exact start and terminal under
182
+ the unchanged writer fence; `direct:<nativeTurnId>` and the native Turn are both
183
+ required. This bounded latest observation does not make the input client-owned.
184
+ This uses the existing Inbox and canonical observer, including the original result.
185
+ It does not submit input, query another native client, restart a Host, or infer
186
+ quiescence from elapsed time/idle state. Normal terminal processing signals again
187
+ after releasing Host occupancy. Unknown acceptance remains isolated unless exact
188
+ evidence settles that input. Explicit stop/replacement and external writers remain
189
+ hard boundaries.
190
+
191
+ `yui session context <role> --json` includes bounded `backlog` diagnostics: pending
192
+ count, oldest timestamp, target and attempt identities, blocking reason and
193
+ recovery guidance. Live age is in `backlogObservation`, outside the paged source
194
+ digest. `in-flight-or-unobserved-terminal` deliberately does not label a
195
+ long-running accepted Turn as stuck. An unavailable/older Host or missing retained
196
+ terminal produces one record-only runtime-attention Message per exact attempt;
197
+ inspect it through the existing message list/show commands. No recovery Agent,
198
+ private timer, migration or new acknowledgement state is introduced. If the
199
+ Provider itself never reported a terminal, or the original Host lost its evidence,
200
+ automatic reconciliation cannot prove completion: restore the compatible observation
201
+ path or explicitly stop/replace only with the required native quiescence evidence.
202
+ This change does not backfill arbitrary pre-existing historical events, adopt old
203
+ Hosts, or authorize production upgrades/restarts.
204
+
205
+ Controller restart preserves live Hosts; it does not replace their loaded code.
206
+ A supported surviving Host writes raw observations to the immutable Inbox even
207
+ while the Controller is down; the restarted Controller applies them under current
208
+ Session fences. A frozen Manifest's CLI/storage mismatch is a context-read failure,
209
+ not proof that the Host publisher failed: this publisher uses the Inbox/RPC, not
210
+ that CLI. An Endpoint checkout generation is not a complete Host build identity.
211
+ The runtime-attention evidence distinguishes an unavailable control channel,
212
+ rejected control (including implementations without reconciliation), and a missing
213
+ exact retained terminal, with reported Endpoint/event-delivery observations.
214
+ Missing delivery diagnostics mean unreported, not healthy. Unsupported live code
215
+ cannot be repaired by another Controller restart or guessed adoption; inspect the
216
+ original evidence and explicitly authorize any necessary lifecycle recovery.
217
+
142
218
  `task wake show <task> <wake>` now defaults to inputs/results and explicit
143
219
  references, including older referenced originals. It omits unrelated diagnostic
144
220
  events and merely started Runs; `--history` restores full fixed-window discovery.
@@ -55,9 +55,19 @@ Run Context 冻结 Assignment、来源引用、生效配置和工作区边界。
55
55
  和消息正文,而不是把加载成功当成交付物。规划 Pack 不暴露 Project 写范围或 Task 完成
56
56
  权限。
57
57
 
58
- Manifest 指针是恢复定位入口,不是已读凭据。首次进入、新会话、版本变化、压缩或恢复后
59
- 说明缺失时,从其确切路径加载 `yui-runtime`、Role Skills 和 profile;仅当同版本全文仍在
60
- 当前可用上下文中,才复用而不机械重读。系统不保存“已经读过”状态。动态事实仍须刷新:
58
+ Manifest 指针是恢复定位入口,不是已读凭据。每轮开始通过本 Session 的 CLI/Home 执行
59
+ `yui session skills --json`,轻量检查当前路径、内部 `VERSION` 和文件可读性,不读取
60
+ 正文或遍历/冻结资源。内置入口是当前安装目录的 `skills/<id>`,自定义入口是
61
+ `YUI_HOME/skills/<id>`,不包含 Session/内容摘要;安装位置变化后由检查重新解析当前入口。
62
+ Global Manifest 的 `skillCheckCommand` 使用自包含的 `session context <role>`,
63
+ 同时返回 Skill 元数据,因此切换到 Desktop 后不依赖启动 shell 的环境;
64
+ 旧 Global Manifest 的 `loadCommand` 也能执行相同检查。
65
+ 首次进入、版本变化、恢复,以及压缩后全文缺失或可用性不确定时,读取当前 Runtime、
66
+ Role/custom Skills 全文及必要引用、Manifest 的 profile。只有版本未变且全文仍在当前
67
+ 上下文时才复用。系统不保存“已经读过”状态,也不声称检查了模型上下文。
68
+ 历史冻结包仅作审计:缺失或损坏不阻止合法 Session 恢复与消息投递;当前文件缺失则
69
+ 报告具体路径和修复动作。身份、作用域、writer fence 与实际 CLI/Home 不兼容仍会拒绝。
70
+ 动态事实仍须刷新:
61
71
  Leader 通知使用 `currentCommand`,显式 AgentRun 使用真实 Run ID 的
62
72
  `loadCommand`/`expandCommand`,不可直接执行占位模板。
63
73
 
@@ -110,6 +120,44 @@ Leader 本地编辑保留持久事件,但不创建自唤醒。当 Leader 忙
110
120
  Task 叙述。人工输入、显式执行进入和激活失败通过既有唤醒机制及时投递。Worker 结果
111
121
  保留其聚合窗口。忙碌的原生输入仍保留待处理的消息。
112
122
 
123
+ ### Operator 积压与周期恢复
124
+
125
+ Task 完成/取消/退役、Publication 记录/采用及 InputRequest 与持久事件在同一事务中
126
+ 进入 Operator 邮箱;Run 失败继续遵守既有重试和监督路由。邮箱自身就是增量恢复源:
127
+ 通知信号遗漏或 Controller 重启后,既有周期协调读取尚未处理的引用,不依赖 Leader
128
+ 主动报告,也不遍历历史 Task。已结算且无变化的邮箱不会生成消息或调用模型。
129
+
130
+ 最多保留一条未发送的工程通知,后续事实在原邮箱中合并,直到该通知已接受或明确未投递。
131
+ 每次认领最多 64 个来源信号,读取精确记录并保留未认领后缀。Global 投递每个 Role 每轮
132
+ 最多读取 64 条待处理消息;已证明失效的原始消息保留未投递证据,后续页由下一轮处理。
133
+ interrupt-then 的预留输入不会因超出普通页而被跳过,用户输入顺序和未知效果围栏保持不变。
134
+ SQL 聚合仍检查匹配的存储行;这些上界约束物化记录和每轮投递工作,不限制持久邮箱或数据库大小。
135
+
136
+ 完整周期协调可要求原 Host 重发它已经观察到的精确终态,严格绑定 Session、
137
+ authority、attempt 和 native Turn。Desktop direct 回合还要求该 Host 保留同一 writer 围栏下
138
+ 精确匹配的开始和结束,同时核对 `direct:<nativeTurnId>` 与 native Turn;只保留最近一次
139
+ 观察,不把 direct 输入改为 client-owned。通过原 Inbox 和规范观察器处理完整结果;不提交新输入、
140
+ 另开原生客户端查询、重启 Host,也不凭时间或 idle 推定静止。正常终态在释放 Host 占用后
141
+ 再次发出队列信号。未知接受只有精确证据才能结算,显式停止/替换及外部 writer 仍是硬边界。
142
+
143
+ `yui session context <role> --json` 的 `backlog` 提供有界待投递数量、最老时间、
144
+ 目标与尝试身份、阻塞原因和恢复指引;实时年龄位于分页摘要外的 `backlogObservation`,
145
+ 不会令分页游标因时间流逝失效。`in-flight-or-unobserved-terminal` 不把长期 accepted
146
+ 误报为卡死。Host 不可达、不支持重观察或缺少保留终态时,每个精确尝试只留下一个 record-only
147
+ runtime-attention Message,可通过既有 message list/show 查看,不自动唤醒恢复 Agent。
148
+ 如果 Provider 从未报告终态,或原 Host 已失去证据,则不能自动证明完成;须恢复兼容观察路径,
149
+ 或在取得原生静止证据后显式停止/替换。不新增私有定时器、迁移或 ack 状态,不猜补任意旧历史、
150
+ 收养旧 Host,也不授权生产升级/重启。
151
+
152
+ Controller 重启保留存活 Host,不替换它们已加载的代码。支持当前协议的原 Host 在 Controller
153
+ 停机期间仍把原始观察写入不可变 Inbox,重启后由 Controller 按当前 Session 围栏应用。
154
+ 冻结 Manifest 的旧 CLI/storage 不匹配是 Context 读取失败,不足以证明 Host 发布失败:
155
+ 发布器使用 Inbox/RPC,不执行该 CLI。Endpoint 的 checkout generation 也不是完整 Host
156
+ 构建身份。runtime-attention 区分控制通道不可达、控制被拒绝(包括实现不支持协调)和
157
+ 精确终态未保留,并附已报告的 Endpoint/eventDelivery 观察;缺少诊断表示未报告,而非健康。
158
+ 再次重启 Controller 或猜测 adoption 不能修复不支持的存活代码;须检查原证据,并为必要的
159
+ 生命周期恢复另行取得明确授权。
160
+
113
161
  | 证据 | 处置 |
114
162
  | --- | --- |
115
163
  | 忙碌且已证明未接受 | 保留输入;新的传输尝试可以使用同一 Session |