@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,6 @@
1
1
  import { accessSync, constants, existsSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
- import Database from "better-sqlite3";
3
+ import Database, { selectSqliteDriver } from "../sqlite/driver.js";
4
4
  import { inspectPty } from "./ptyProbe.js";
5
5
  import { gitVersionSupport } from "../repository/gitCompatibility.js";
6
6
  import { resolveExecutable } from "../external/pinnedCommandRunner.js";
@@ -31,6 +31,7 @@ import { CommandExecutionError } from "../tmux/commandExecutor.js";
31
31
  * and never block an update.
32
32
  */
33
33
  export const STORAGE_DOCTOR_CHECK_NAMES = Object.freeze([
34
+ "sqlite driver",
34
35
  "storage schema",
35
36
  "storage compatibility",
36
37
  "storage state"
@@ -38,7 +39,7 @@ export const STORAGE_DOCTOR_CHECK_NAMES = Object.freeze([
38
39
  /**
39
40
  * Classify the storage checks into a machine-readable health verdict. This is the
40
41
  * canonical, parseable signal the update post-verify consumes (P1-3): storage is
41
- * healthy only when schema, compatibility, and state are all `ok`. Any
42
+ * healthy only when driver, schema, compatibility, and state are all `ok`. Any
42
43
  * `unsupported` (version mismatch / needs-new-version), `invalid` (corrupted /
43
44
  * unreadable), or `missing` (uninitialized) storage check is blocking — even
44
45
  * and the machine-readable command exits non-zero.
@@ -119,6 +120,7 @@ export async function getDoctorChecks(env, executor) {
119
120
  async function inspectDoctor(env, executor) {
120
121
  const home = resolveYuiHome(env);
121
122
  const homeCheck = checkHome(home);
123
+ const driverCheck = checkSqliteDriver();
122
124
  const schema = readSchema(home);
123
125
  const compatibility = inspectCompatibility(home, homeCheck, schema);
124
126
  const schemaCheck = checkSchema(schema);
@@ -135,6 +137,7 @@ async function inspectDoctor(env, executor) {
135
137
  return {
136
138
  checks: [
137
139
  homeCheck,
140
+ driverCheck,
138
141
  schemaCheck,
139
142
  compatibility.check,
140
143
  storage.check,
@@ -148,6 +151,26 @@ async function inspectDoctor(env, executor) {
148
151
  review: review.report
149
152
  };
150
153
  }
154
+ export function checkSqliteDriver() {
155
+ try {
156
+ const selected = selectSqliteDriver();
157
+ return {
158
+ name: "sqlite driver",
159
+ status: "ok",
160
+ detail: `${selected.name}@${selected.version}; SQLite ${selected.sqliteVersion}; `
161
+ + (selected.fallbackReason === undefined
162
+ ? "no fallback"
163
+ : `fallback: ${selected.fallbackReason}`)
164
+ + ". Isolated native connection probe; does not validate Home data."
165
+ };
166
+ }
167
+ catch (error) {
168
+ return {
169
+ name: "sqlite driver", status: "invalid",
170
+ detail: `Native driver probe failed (not a Home diagnosis): ${error instanceof Error ? error.message : String(error)}`
171
+ };
172
+ }
173
+ }
151
174
  /** Query the authenticated Controller endpoint without starting it. */
152
175
  export async function checkController(home, probe = callController) {
153
176
  try {
@@ -2,7 +2,7 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { validateExecutionEnvironmentSnapshot } from "../resources/projectResource.js";
3
3
  import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
4
4
  import { resolveAgentAdapter } from "./agentAdapter.js";
5
- import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
5
+ import { loadYuiSkillContexts, roleSessionKind, roleSkillIds } from "../context/roleSessionContext.js";
6
6
  import { validateSkillPackageRef } from "../context/skillPackage.js";
7
7
  import { isAgentAdapterId } from "../agent/adapterCatalog.js";
8
8
  import { adapterIdForExecutionComponent, isAgentExecutionComponentId, resolveAgentExecutionComponent } from "../agent/executionComponents.js";
@@ -24,7 +24,7 @@ export function resolveEffectiveLaunch(input) {
24
24
  const continuing = existing?.status === "active";
25
25
  const skillPackages = continuing ? existing.effective.skillPackages
26
26
  : home === undefined ? undefined
27
- : compileRoleSessionContext(home, input.role, "taskId" in input.role ? { scope: "task", taskId: input.role.taskId } : { scope: "global" }, { purpose: input.purpose }).skills.map(skill => skill.package);
27
+ : loadYuiSkillContexts(home, roleSkillIds(input.role, roleSessionKind(input.role, "taskId" in input.role ? { scope: "task", taskId: input.role.taskId } : { scope: "global" }, input.purpose))).map(skill => skill.package);
28
28
  return snapshotFromConfig({
29
29
  executionAuthority: input.executionAuthority ?? (input.purpose === "planning" ? "planning" : "delivery"),
30
30
  sourceDesiredRevision: input.role.launchRevision,
@@ -337,12 +337,10 @@ export class FileRoleLaunchPlanner {
337
337
  Object.assign(launchEnvironment, runtimeIsolation === undefined
338
338
  ? {}
339
339
  : taskRuntimeIsolationEnvironment(runtimeIsolation));
340
- const skillPackages = input.mode === "resume"
341
- ? existingSession?.effective.skillPackages : effective.skillPackages;
342
- if (skillPackages === undefined) {
343
- throw new Error("This launch has no frozen Skill package evidence (pre-1.5 or externally recorded Session). Create a new Session from current Role configuration; historical Skill versions cannot be reconstructed.");
344
- }
345
- const baseSessionContext = compileRoleSessionContext(this.home, launchRole, owner, { ...sessionPolicy, skillPackages });
340
+ // Frozen packages describe historical materialization, not Session identity
341
+ // or current instruction availability. Never require or reconstruct them
342
+ // when reconnecting a valid old Session.
343
+ const baseSessionContext = compileRoleSessionContext(this.home, launchRole, owner, sessionPolicy);
346
344
  const bootstrap = materializeSessionBootstrap({
347
345
  yuiHome: this.home,
348
346
  role: launchRole,
@@ -69,7 +69,7 @@ export function taskNotificationPresentation(wake, store) {
69
69
  ...(messages.length > 4 ? [`${messages.length - 4} more messages in the fixed wake.`] : []),
70
70
  `Refresh: yui task context ${wake.taskId} --json`,
71
71
  `Read inputs/results: yui task wake show ${wake.taskId} ${wake.id} --json`,
72
- "Apply yui-leader (from the current Manifest); read relevant originals in full and advance authorized work."
72
+ "Check current Skills each turn: yui session skills --json. Apply current yui-leader; read relevant originals in full and advance authorized work."
73
73
  ].join("\n");
74
74
  }
75
75
  export function globalMessagePresentation(roleName, messageId, message) {
@@ -80,6 +80,6 @@ export function globalMessagePresentation(roleName, messageId, message) {
80
80
  ...(summary === undefined ? [] : [`Author summary (message-body): ${JSON.stringify(summary)}`]),
81
81
  "Refresh Session Context using the current Manifest's loadCommand.",
82
82
  `Read original: yui role message show ${roleName} ${messageId} --json`,
83
- `Apply ${roleName === "operator" ? "yui-operator" : "yui-runtime"} from the current Manifest within existing authority.`
83
+ `The Global loadCommand checks current Skill versions each turn. Apply current ${roleName === "operator" ? "yui-operator" : "yui-runtime"} within existing authority.`
84
84
  ].join("\n");
85
85
  }
@@ -10,7 +10,7 @@ export function createOperatorBatchPresentation(batchId, items) {
10
10
  receiptId: `operator-batch:${batchId}`,
11
11
  text: [
12
12
  "[Yui updates]",
13
- "Apply yui-operator from the current Manifest: inspect these originals and present material changes or decisions.",
13
+ "Check current Skills each turn via the Global Manifest's loadCommand. Apply current yui-operator; inspect these originals and present material changes or decisions.",
14
14
  ...items.flatMap(renderItem)
15
15
  ].join("\n")
16
16
  };
@@ -0,0 +1,69 @@
1
+ import { providerRetryPending } from "../runtime/providerRetry.js";
2
+ import { hasRuntimeCleanupObligation } from "../runtime/lifecycleReservation.js";
3
+ /** Bounded, derived diagnostics only; never an acknowledgement or recovery authority. */
4
+ export function globalInputBacklog(pending, sessions, now = new Date(), mailboxes = {}) {
5
+ const first = pending[0];
6
+ const run = sessions?.providerBinding?.run;
7
+ const session = sessions?.sessions[sessions.activeAgentId];
8
+ const authority = sessions?.providerBinding?.authority;
9
+ const batches = [mailboxes.attention?.pending, mailboxes.attention?.processing?.batch]
10
+ .filter(batch => batch != null);
11
+ const coalescedFactCount = batches.reduce((count, batch) => count + batch.requestCount, 0);
12
+ let reason = "ready";
13
+ let recovery = "The Controller delivers at the next legal opportunity; do not duplicate the input.";
14
+ if (first === undefined && coalescedFactCount === 0)
15
+ reason = "empty";
16
+ else if (hasRuntimeCleanupObligation(mailboxes.lifecycle ?? null)) {
17
+ reason = "lifecycle-cleanup";
18
+ recovery = "Complete the existing exact-owner stop/replacement; queued input cannot revive the Session.";
19
+ }
20
+ else if (session?.status !== "active") {
21
+ reason = session === undefined ? "session-unavailable" : "session-stopped";
22
+ recovery = "Inspect Session lifecycle. Explicit stop/replacement is not undone by background delivery.";
23
+ }
24
+ else if (authority?.owner !== "controller") {
25
+ reason = "external-or-unknown-writer";
26
+ recovery = "Resolve the current writer/authority explicitly; no background takeover is allowed.";
27
+ }
28
+ else if (providerRetryPending(sessions?.providerBinding)) {
29
+ reason = "provider-retry";
30
+ recovery = "The existing bounded Provider retry owns this input; inspect session retry before intervening.";
31
+ }
32
+ else if (run && ["submitting", "accepted", "delivery-unknown"].includes(run.status)) {
33
+ reason = run.status === "accepted" ? "in-flight-or-unobserved-terminal" : run.status;
34
+ recovery = "Age does not prove a stalled Turn. Periodic reconciliation asks only the original Host for exact retained terminal evidence. Inspect recent runtime-attention Messages if the Host is unobservable; never resend unknown input.";
35
+ }
36
+ else if (first?.inbox) {
37
+ reason = "agent-inbox-handling";
38
+ recovery = "Read the exact inbox original and prior handling claim; explicitly acknowledge after processing. No automatic replay or timeout release.";
39
+ }
40
+ else if (first?.control && first.control.outcome !== "rejected") {
41
+ reason = "message-acceptance-unresolved";
42
+ recovery = "Inspect this original Message's exact control receipt; do not replay or bypass uncertain input.";
43
+ }
44
+ else if (first?.interruptThen) {
45
+ reason = "reserved-interrupt-then";
46
+ recovery = "Only the original Session, authority and exact terminal release the reserved continuation.";
47
+ }
48
+ else if (first?.deliveryTarget && (first.deliveryTarget.agentId !== sessions?.activeAgentId
49
+ || first.deliveryTarget.nativeSessionId !== session.nativeSessionId)) {
50
+ reason = "stale-target";
51
+ recovery = "Unsubmitted stale messages are retained with a nondelivery reason; no cross-Session input copy.";
52
+ }
53
+ const oldestCreatedAt = [...pending.map(message => message.createdAt), ...batches.map(batch => batch.firstQueuedAt)]
54
+ .reduce((oldest, at) => oldest === null || at < oldest ? at : oldest, null);
55
+ return {
56
+ pendingCount: pending.length, coalescedFactCount, oldestCreatedAt,
57
+ oldestAgeMs: oldestCreatedAt === null ? null : Math.max(0, now.getTime() - Date.parse(oldestCreatedAt)),
58
+ reason, recovery,
59
+ target: session === undefined ? null : {
60
+ agentId: session.agentId, nativeSessionId: session.nativeSessionId,
61
+ authority: authority ?? null, attemptId: run?.attemptId ?? null, nativeTurnId: run?.nativeTurnId ?? null
62
+ },
63
+ messages: pending.slice(0, 8).map(message => ({
64
+ id: message.id, createdAt: message.createdAt, deliveryTarget: message.deliveryTarget ?? null,
65
+ control: message.control ?? null, interruptThen: message.interruptThen ?? null
66
+ })),
67
+ omittedMessages: Math.max(0, pending.length - 8)
68
+ };
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { requireKnownFields, requireText, requireTimestamp } from "../domain/validation.js";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ export function validateInboxReceipt(receipt) {
4
+ if (receipt === undefined)
5
+ return;
6
+ requireKnownFields(receipt, ["claim", "ack"], "Inbox receipt");
7
+ for (const [kind, entry] of Object.entries(receipt)) {
8
+ requireKnownFields(entry, kind === "claim" ? ["requestId", "actor", "at"]
9
+ : ["requestId", "actor", "at", "reason"], "Inbox action");
10
+ requireText(entry.requestId, "Inbox request id");
11
+ requireTimestamp(entry.at, "Inbox timestamp");
12
+ const actor = entry.actor;
13
+ requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId"], "Inbox actor");
14
+ if (!["task", "global"].includes(actor.scope)
15
+ || (actor.scope === "task") !== (actor.taskId !== undefined))
16
+ throw new Error("Inbox actor scope is invalid.");
17
+ for (const value of Object.values(actor))
18
+ requireText(value, "Inbox actor identity");
19
+ if (kind === "ack")
20
+ requireText(entry.reason, "Inbox disposition");
21
+ }
22
+ if (!receipt.claim)
23
+ throw new Error("Inbox receipt requires its original claim.");
24
+ }
25
+ /** A stale automatic writer cannot erase an active claim between its read and
26
+ * Host admission. The original claim and any ack are immutable audit facts. */
27
+ export function assertInboxReceiptPreserved(previous, next) {
28
+ if (previous !== undefined && (!isDeepStrictEqual(previous.claim, next?.claim)
29
+ || previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack))) {
30
+ throw new Error("Inbox handling ownership changed; reread the original instead of overwriting its receipt.");
31
+ }
32
+ }
@@ -1,5 +1,6 @@
1
1
  import { validateTaskRecordReference } from "../task/taskRecordReference.js";
2
2
  import { requireKnownFields } from "../domain/validation.js";
3
+ import { validateInboxReceipt } from "./inboxReceipt.js";
3
4
  export const TASK_MESSAGE_KINDS = ["user", "operator", "role-result", "system"];
4
5
  /**
5
6
  * How a user/operator submission asks its Task to react (task-32 Requirement A).
@@ -207,8 +208,9 @@ export function validateTaskMessage(message) {
207
208
  throw new Error("Task Message must use schemaVersion 1.");
208
209
  requireKnownFields(message, [
209
210
  "schemaVersion", "id", "taskId", "kind", "author", "body", "intent", "submissionKey", "submissionReceipt",
210
- "inputControl", "interruptThen", "control", "runId", "resultRef", "workItemId", "recipient", "continuation", "handovers", "createdAt"
211
+ "inputControl", "interruptThen", "control", "runId", "resultRef", "workItemId", "recipient", "continuation", "handovers", "createdAt", "inbox"
211
212
  ], "Task Message");
213
+ validateInboxReceipt(message.inbox);
212
214
  validateTaskRecordReference({ taskId: message.taskId, localId: message.id }, "message");
213
215
  requireText(message.body, "Message body");
214
216
  validateKindAndAuthor(message.kind, message.author);
@@ -394,6 +396,7 @@ export function createGlobalRoleMessage(id, roleName, body, kind, author, now, c
394
396
  return message;
395
397
  }
396
398
  export function validateGlobalRoleMessage(message) {
399
+ validateInboxReceipt(message.inbox);
397
400
  if (message.schemaVersion !== 1)
398
401
  throw new Error("Global Role Message must use schemaVersion 1.");
399
402
  validateGlobalRoleMessageId(message.id);
@@ -517,6 +520,8 @@ export function markGlobalRoleMessageDelivered(message, now, via = "provider") {
517
520
  * keeps resolving to this same Message and never creates a second input.
518
521
  */
519
522
  export function claimGlobalRoleMessageInterruptThen(message, claim) {
523
+ if (message.inbox !== undefined)
524
+ throw new Error("Agent inbox handling already owns this Message; it cannot be replayed through interrupt-then.");
520
525
  const { inputControl, notDelivered: _priorNondelivery, ...rest } = message;
521
526
  const claimed = {
522
527
  ...rest,
@@ -10,6 +10,7 @@ import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalizatio
10
10
  import { sourceRunContextValue } from "../context/sourceRunContext.js";
11
11
  import { requireManagedTaskCaller } from "../runtime/managedCaller.js";
12
12
  import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
13
+ import { isTaskRecordRetired, operationalTaskRecords } from "../task/taskRecordRetirement.js";
13
14
  export function resolveMessageRecipient(store, taskId, roleName, scope) {
14
15
  if (scope.reviewRoundId !== undefined) {
15
16
  const round = store.getReviewRound(taskId, scope.reviewRoundId);
@@ -28,8 +29,11 @@ export function resolveMessageRecipient(store, taskId, roleName, scope) {
28
29
  throw new Error("No existing Dispatch owns this Role and work scope.");
29
30
  return { roleName, ownerRunId: previous.id, ...scope };
30
31
  }
31
- /** Bounded diagnosis, not automatic ownership repair. */
32
- export function messageContinuationBlocker(store, message) {
32
+ /** Current Assignment authority, independent of eligibility to start a new Run.
33
+ * A Reviewer still owns its frozen clarification when Task main advances. */
34
+ export function messageAssignmentBlocker(store, message) {
35
+ if (isTaskRecordRetired(store.listEvents(message.taskId), "message", message.id))
36
+ return "message-retired";
33
37
  const target = message.recipient;
34
38
  if (target?.ownerRunId === undefined)
35
39
  return "recipient-assignment-missing";
@@ -78,22 +82,29 @@ export function messageContinuationBlocker(store, message) {
78
82
  && latest.inputs[0]?.input.source.channel !== "message-continuation")
79
83
  return "assignment-changed";
80
84
  if (target.reviewRoundId !== undefined) {
81
- const round = store.getReviewRound(message.taskId, target.reviewRoundId);
82
- if (round === null)
83
- return "review-round-missing";
85
+ const validation = validateExactRunReviewRound(store, latest ?? owner, { allowTerminal: true });
86
+ if (validation.disposition !== "applied")
87
+ return validation.reason ?? "review-candidate-stale";
88
+ }
89
+ return undefined;
90
+ }
91
+ /** Starting another Run additionally requires the reviewed candidate to remain current. */
92
+ export function messageContinuationBlocker(store, message) {
93
+ const invalid = messageAssignmentBlocker(store, message);
94
+ if (invalid !== undefined)
95
+ return invalid;
96
+ const reviewRoundId = message.recipient?.reviewRoundId;
97
+ if (reviewRoundId !== undefined) {
98
+ const round = store.getReviewRound(message.taskId, reviewRoundId);
84
99
  if (round.scope === "task") {
100
+ const task = store.getTask(message.taskId);
85
101
  if (round.taskCandidate?.projects.some((project) => task.projectBindings.find((binding) => binding.projectId === project.projectId)?.currentCommit !== project.commit)) {
86
102
  return "review-candidate-stale";
87
103
  }
88
104
  }
89
- else {
90
- const item = store.getWorkItem(message.taskId, round.workItemId);
91
- if (item?.candidates.at(-1)?.id !== round.candidateId)
92
- return "review-candidate-stale";
105
+ else if (store.getWorkItem(message.taskId, round.workItemId)?.candidates.at(-1)?.id !== round.candidateId) {
106
+ return "review-candidate-stale";
93
107
  }
94
- const validation = validateExactRunReviewRound(store, latest ?? owner, { allowTerminal: true });
95
- if (validation.disposition !== "applied")
96
- return validation.reason ?? "review-candidate-stale";
97
108
  }
98
109
  return undefined;
99
110
  }
@@ -189,7 +200,11 @@ export function interruptThenTerminalState(store, message) {
189
200
  * remain the pending authority; Mailbox is only the existing scheduling hint.
190
201
  * Reserving inputs and creating the next Run is one atomic effect. */
191
202
  export function prepareMessageContinuations(store, taskId, now, roleName) {
192
- const pending = store.listMessages(taskId).filter((message) => message.recipient?.roleName === roleName && message.recipient.ownerRunId !== undefined
203
+ const messages = operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message");
204
+ const claims = messages.filter(message => message.recipient?.roleName === roleName
205
+ && message.inbox !== undefined && message.inbox.ack === undefined);
206
+ const pending = messages.filter((message) => message.recipient?.roleName === roleName && message.recipient.ownerRunId !== undefined
207
+ && message.inbox === undefined
193
208
  && message.continuation?.runId === undefined
194
209
  // A steer targets the exact current native turn, never a queued next Run.
195
210
  // If its live attempt did not submit, the message stays saved for the
@@ -197,6 +212,12 @@ export function prepareMessageContinuations(store, taskId, now, roleName) {
197
212
  && message.inputControl?.action !== "steer");
198
213
  for (const message of pending) {
199
214
  const recipient = message.recipient;
215
+ // Retain ordering within the claimed Assignment, not across unrelated
216
+ // work owned by the same Role. Only ack or explicit retirement settles a
217
+ // claim's scheduling obligation; neither time nor a failed Run does.
218
+ if (claims.some(claim => claim.recipient?.workItemId === recipient.workItemId
219
+ && claim.recipient?.reviewRoundId === recipient.reviewRoundId))
220
+ continue;
200
221
  const blocker = messageContinuationBlocker(store, message);
201
222
  if (blocker !== undefined) {
202
223
  markNotDelivered(store, message, blocker);
@@ -0,0 +1,21 @@
1
+ import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
2
+ export function latestMessageUpdate(store, message) {
3
+ return store.listEvents(message.taskId).filter(event => event.type === "message.updated" && event.payload.messageId === message.id).at(-1);
4
+ }
5
+ /** Leader steer has no Assignment recipient; ordinary Task records are not
6
+ * private inbox messages. Queued Leader inputs remain fixed wake windows. */
7
+ export function isLeaderSteer(message) {
8
+ return message.recipient === undefined && message.inputControl?.action === "steer";
9
+ }
10
+ /** Derive ownership from original receipts, never from disposable mailboxes. */
11
+ export function taskMessageInboxReceipt(store, message) {
12
+ const revision = latestMessageUpdate(store, message);
13
+ return message.inbox ?? store.listTaskWakes(message.taskId).find(wake => wake.inbox !== undefined
14
+ && (revision === undefined || wake.refs?.some(ref => ref.type === "event" && ref.taskId === message.taskId && ref.id === revision.id))
15
+ && wake.refs?.some(ref => ref.type === "message" && ref.taskId === message.taskId && ref.id === message.id))?.inbox;
16
+ }
17
+ export function unfinishedLeaderInbox(store, taskId) {
18
+ return store.listTaskWakes(taskId).some(wake => wake.inbox !== undefined && wake.inbox.ack === undefined)
19
+ || operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message").some(message => isLeaderSteer(message)
20
+ && message.inbox !== undefined && message.inbox.ack === undefined);
21
+ }
@@ -1,4 +1,4 @@
1
- import Database from 'better-sqlite3';
1
+ import Database from "../../sqlite/driver.js";
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
4
  import { existsSync, lstatSync } from 'node:fs';
@@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import Database from "better-sqlite3";
15
+ import Database from "../sqlite/driver.js";
16
16
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "../storage/storageVersions.js";
17
17
  import { inspectStorageSchema } from "../storage/storageSchema.js";
18
18
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
@@ -13,7 +13,7 @@
13
13
  import { existsSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { isDeepStrictEqual } from "node:util";
16
- import Database from "better-sqlite3";
16
+ import Database from "../sqlite/driver.js";
17
17
  import { validateSqliteSchema } from "../storage/sqliteSchema.js";
18
18
  import { emptyResourceRegistry, parseResourceRegistryState } from "./resourceRegistry.js";
19
19
  import { RESOURCE_REGISTRY_SCHEMA_VERSION } from "./resourceTypes.js";
@@ -93,6 +93,9 @@ export async function runAgentHost(input) {
93
93
  let activeRunPayload;
94
94
  let lastTerminalPayload;
95
95
  let lastTerminal;
96
+ // One exact direct Turn witnessed by this Endpoint under its original fence.
97
+ // This is recovery evidence, never ownership of the Desktop client's input.
98
+ let directObservation;
96
99
  let activeRunAttemptId;
97
100
  let activeNativeTurnId;
98
101
  let codexClientAttachedAt;
@@ -166,6 +169,7 @@ export async function runAgentHost(input) {
166
169
  const handleStarted = (started, observedPayload = sessionPayload) => {
167
170
  if (started.clientOwned || observedPayload === undefined)
168
171
  return;
172
+ const observedAuthority = authority === undefined ? undefined : { ...authority };
169
173
  updateSnapshot(hostSnapshot("busy", {
170
174
  adapterId: observedPayload.providerControl?.adapterId,
171
175
  processInstanceId: session?.processInstanceId,
@@ -175,6 +179,22 @@ export async function runAgentHost(input) {
175
179
  ...authorityFields()
176
180
  }));
177
181
  void enqueueSerialized(async () => {
182
+ // Desktop can start another Turn before the Controller's periodic pass.
183
+ // Persist the previous exact terminal before replacing its bounded cache
184
+ // or publishing the new start. Never clear the newer native writer.
185
+ const previous = directObservation;
186
+ if (previous?.terminal !== undefined && authority !== undefined
187
+ && sameProviderAuthorityFence(previous.authority, authority)
188
+ && previous.started.nativeSessionId === session?.nativeSessionId) {
189
+ await publishStructuredProviderTerminal({
190
+ home: input.home, environment: previous.payload.environment, terminal: previous.terminal
191
+ });
192
+ }
193
+ if (session !== undefined && started.nativeSessionId === session.nativeSessionId
194
+ && started.conversationId === session.conversationId && observedAuthority !== undefined
195
+ && authority !== undefined && sameProviderAuthorityFence(observedAuthority, authority)) {
196
+ directObservation = { started, payload: observedPayload, authority: observedAuthority };
197
+ }
178
198
  await publishStructuredProviderStarted({
179
199
  home: input.home,
180
200
  environment: observedPayload.environment,
@@ -194,6 +214,14 @@ export async function runAgentHost(input) {
194
214
  // AgentRun audit record. It has no WorkItem ownership and no managed input
195
215
  // fence; the runtime observer records only visible input/output.
196
216
  void enqueueSerialized(async () => {
217
+ const direct = directObservation;
218
+ if (direct !== undefined && authority !== undefined
219
+ && sameProviderAuthorityFence(direct.authority, authority)
220
+ && direct.started.nativeSessionId === terminal.nativeSessionId
221
+ && direct.started.conversationId === terminal.conversationId
222
+ && direct.started.nativeTurnId === terminal.nativeTurnId) {
223
+ direct.terminal = terminal;
224
+ }
197
225
  await publishStructuredProviderTerminal({
198
226
  home: input.home,
199
227
  environment: busyPayload.environment,
@@ -274,6 +302,7 @@ export async function runAgentHost(input) {
274
302
  conversationId: terminal.conversationId,
275
303
  ...authorityFields()
276
304
  }));
305
+ signalRoleMailbox(input.home, currentPayload);
277
306
  promptHuman();
278
307
  }
279
308
  catch (error) {
@@ -621,6 +650,7 @@ export async function runAgentHost(input) {
621
650
  await recordProviderConnection(input.home, next, started.session);
622
651
  lastTerminalPayload = undefined;
623
652
  lastTerminal = undefined;
653
+ directObservation = undefined;
624
654
  // Recoverable means a later process can rebind this Conversation by
625
655
  // its native id. The Endpoint answers that from the Driver capability
626
656
  // or, where the protocol settles it per connection, from what this
@@ -1157,6 +1187,51 @@ export async function runAgentHost(input) {
1157
1187
  }
1158
1188
  return controlResult("accepted", { ...snapshot, nativeTui: !!nativeTui });
1159
1189
  }
1190
+ if (request.type === "reconcile-turn") {
1191
+ if (session === undefined || session.nativeSessionId !== request.nativeSessionId
1192
+ || authority === undefined || !sameProviderAuthorityFence(authority, request.authority)) {
1193
+ throw new Error("Terminal reconciliation targets a different Session or authority.");
1194
+ }
1195
+ const direct = directObservation;
1196
+ const directMatch = direct?.terminal !== undefined
1197
+ && request.nativeTurnId !== undefined
1198
+ && request.attemptId === `direct:${request.nativeTurnId}`
1199
+ && direct.started.nativeTurnId === request.nativeTurnId
1200
+ && direct.started.nativeSessionId === request.nativeSessionId
1201
+ && sameProviderAuthorityFence(direct.authority, request.authority);
1202
+ const terminal = directMatch ? direct.terminal : lastTerminal;
1203
+ const terminalPayload = directMatch ? direct.payload : lastTerminalPayload;
1204
+ if (terminal === undefined || terminalPayload === undefined
1205
+ || terminal.nativeSessionId !== request.nativeSessionId
1206
+ || !directMatch && (terminal.clientOwned !== true || terminal.attemptId !== request.attemptId)
1207
+ || request.nativeTurnId !== undefined && terminal.nativeTurnId !== request.nativeTurnId) {
1208
+ return { ...controlResult("status", snapshot), reconciliation: "unobserved" };
1209
+ }
1210
+ // Serialize with submission/terminal persistence. The original Host and
1211
+ // original launch envelope remain the observer, including the full result.
1212
+ await enqueueSerialized(async () => {
1213
+ if (session?.nativeSessionId !== request.nativeSessionId || authority === undefined
1214
+ || !sameProviderAuthorityFence(authority, request.authority)
1215
+ || directMatch && directObservation !== direct) {
1216
+ throw new Error("Session changed before terminal reconciliation.");
1217
+ }
1218
+ await publishStructuredProviderTerminal({
1219
+ home: input.home, environment: terminalPayload.environment, terminal
1220
+ });
1221
+ if (!directMatch && activeRunAttemptId === terminal.attemptId) {
1222
+ activeRunPayload = undefined;
1223
+ activeRunAttemptId = undefined;
1224
+ activeNativeTurnId = undefined;
1225
+ updateSnapshot(hostSnapshot(session.inspect().activeNativeTurnId === undefined ? "idle" : "busy", {
1226
+ adapterId: session.adapterId, processInstanceId: session.processInstanceId,
1227
+ nativeSessionId: session.nativeSessionId, conversationId: session.conversationId,
1228
+ ...authorityFields()
1229
+ }));
1230
+ }
1231
+ signalRoleMailbox(input.home, terminalPayload);
1232
+ });
1233
+ return { ...controlResult("status", snapshot), reconciliation: "terminal-republished" };
1234
+ }
1160
1235
  if (request.type === "status") {
1161
1236
  // Read the Agent's configuration at answer time, not from the stored
1162
1237
  // snapshot. The Agent may have changed it since the last state
@@ -1461,6 +1536,11 @@ export async function inspectAgentHost(input) {
1461
1536
  });
1462
1537
  return result.snapshot;
1463
1538
  }
1539
+ export async function reconcileAgentHostTurn(input) {
1540
+ // Periodic observation must not hold the scheduler behind an unavailable Host.
1541
+ // A timeout is only an observation failure, never permission to submit again.
1542
+ return exchangeAgentHostControl(agentHostControlSocketPath(input), input.control, 1_000);
1543
+ }
1464
1544
  export async function waitForAgentHostLaunchAck(input) {
1465
1545
  const deadline = Date.now() + (input.timeoutMs ?? AGENT_HOST_READY_TIMEOUT_MS);
1466
1546
  let lastError;
@@ -1616,7 +1696,7 @@ export async function openAgentHostControl(home, payload, snapshot, dispatch) {
1616
1696
  ...result,
1617
1697
  snapshot: {
1618
1698
  ...result.snapshot,
1619
- ...(payload.environment.YUI_SESSION_SCOPE !== "task" ? {} : {
1699
+ ...(!["task", "global"].includes(payload.environment.YUI_SESSION_SCOPE ?? "") ? {} : {
1620
1700
  eventDelivery: structuredProviderEventDelivery(home, payload.environment, result.snapshot.nativeSessionId)
1621
1701
  })
1622
1702
  }
@@ -1691,7 +1771,8 @@ function boundControlResponse(result) {
1691
1771
  // visible through an otherwise successful status request.
1692
1772
  result = {
1693
1773
  ...controlResult(result.outcome, validateSnapshot(result.snapshot), result.failure === undefined ? undefined : providerDeliveryFailure(result.failure)),
1694
- ...(result.cancellation === undefined ? {} : { cancellation: result.cancellation })
1774
+ ...(result.cancellation === undefined ? {} : { cancellation: result.cancellation }),
1775
+ ...(result.reconciliation === undefined ? {} : { reconciliation: result.reconciliation })
1695
1776
  };
1696
1777
  if (withinControlBound(result))
1697
1778
  return result;
@@ -1709,29 +1790,29 @@ function boundControlResponse(result) {
1709
1790
  };
1710
1791
  // Never make an omitted option list look like a complete empty enumeration.
1711
1792
  if (result.snapshot.runConfiguration?.status === "observed") {
1712
- result = controlResult(result.outcome, validateSnapshot({
1713
- ...result.snapshot,
1714
- runConfiguration: unknownAgentRunConfiguration("The Agent's reported configuration is too large for the Agent Host "
1715
- + "control response, so it could not be carried in full and none of it "
1716
- + "is shown rather than part of it.")
1717
- }), result.failure);
1793
+ result = { ...result, ...controlResult(result.outcome, validateSnapshot({
1794
+ ...result.snapshot,
1795
+ runConfiguration: unknownAgentRunConfiguration("The Agent's reported configuration is too large for the Agent Host "
1796
+ + "control response, so it could not be carried in full and none of it "
1797
+ + "is shown rather than part of it.")
1798
+ }), result.failure) };
1718
1799
  if (withinControlBound(result))
1719
1800
  return result;
1720
1801
  }
1721
- let bounded = controlResult(result.outcome, {
1722
- ...result.snapshot,
1723
- ...(result.snapshot.detail === undefined ? {} : { detail: clip(result.snapshot.detail, 1200) })
1724
- }, result.failure === undefined ? undefined : { ...result.failure, detail: clip(result.failure.detail, 1200) });
1802
+ let bounded = { ...result, ...controlResult(result.outcome, {
1803
+ ...result.snapshot,
1804
+ ...(result.snapshot.detail === undefined ? {} : { detail: clip(result.snapshot.detail, 1200) })
1805
+ }, result.failure === undefined ? undefined : { ...result.failure, detail: clip(result.failure.detail, 1200) }) };
1725
1806
  const raw = bounded.failure?.raw;
1726
1807
  if (withinControlBound(bounded) || raw === undefined)
1727
1808
  return bounded;
1728
1809
  let chars = raw.length;
1729
1810
  while (!withinControlBound(bounded) && chars > 0) {
1730
1811
  chars = Math.floor(chars / 2);
1731
- bounded = controlResult(bounded.outcome, bounded.snapshot, {
1732
- ...bounded.failure,
1733
- raw: clip(raw, chars)
1734
- });
1812
+ bounded = { ...bounded, ...controlResult(bounded.outcome, bounded.snapshot, {
1813
+ ...bounded.failure,
1814
+ raw: clip(raw, chars)
1815
+ }) };
1735
1816
  }
1736
1817
  return bounded;
1737
1818
  }
@@ -1764,6 +1845,14 @@ function validateControl(control) {
1764
1845
  }
1765
1846
  if (control.type === "status")
1766
1847
  return Object.freeze({ ...control });
1848
+ if (control.type === "reconcile-turn") {
1849
+ validateIdentity(control.nativeSessionId, "native Session id");
1850
+ validateIdentity(control.attemptId, "Provider input attempt id");
1851
+ if (control.nativeTurnId !== undefined)
1852
+ validateIdentity(control.nativeTurnId, "native Turn id");
1853
+ validateProviderAuthorityFence(control.authority);
1854
+ return Object.freeze({ ...control });
1855
+ }
1767
1856
  if (control.type === "native-open" || control.type === "native-respond" || control.type === "native-model") {
1768
1857
  validateIdentity(control.nativeSessionId, "native Session id");
1769
1858
  if (control.type === "native-model" && (typeof control.model !== "string"