@zq-silk/yui 2.18.0 → 2.20.0

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 (36) hide show
  1. package/dist/cli/commandCatalog.js +2 -0
  2. package/dist/commands/controllerCommands.js +1 -0
  3. package/dist/commands/roleConfiguration.js +17 -0
  4. package/dist/controller/controller.js +10 -0
  5. package/dist/controller/fileSchedulerStoreAdapter.js +26 -2
  6. package/dist/controller/messageHealth.js +354 -0
  7. package/dist/controller/runtime.js +9 -1
  8. package/dist/controller/runtimeLaunchCoordinator.js +12 -1
  9. package/dist/controller/taskHostTerminalRecovery.js +11 -0
  10. package/dist/executor/agentExecutor.js +7 -0
  11. package/dist/executor/fileRoleLaunchPlanner.js +22 -6
  12. package/dist/output/rolePresentation.js +1 -0
  13. package/dist/role/instanceCode.js +7 -0
  14. package/dist/role/role.js +10 -0
  15. package/dist/runtime/agentEndpoint.js +1 -0
  16. package/dist/runtime/agentHost.js +9 -1
  17. package/dist/runtime/launchBroker.js +3 -0
  18. package/dist/runtime/runtimeBinding.js +3 -0
  19. package/dist/runtime/sessionTitle.js +29 -9
  20. package/dist/runtime/structuredProviderHost.js +11 -1
  21. package/dist/runtime/tmuxAdapters.js +4 -0
  22. package/dist/storage/contextRecords.js +16 -0
  23. package/dist/storage/sqliteSchema.js +10 -0
  24. package/dist/storage/sqliteStore.js +16 -0
  25. package/dist/storage/storageVersions.js +1 -1
  26. package/docs/managed-turn-and-session-runtime.md +96 -0
  27. package/docs/managed-turn-and-session-runtime.zh-CN.md +65 -0
  28. package/docs/release-workflow.md +49 -0
  29. package/docs/release-workflow.zh-CN.md +37 -0
  30. package/docs/roles-and-configuration.md +49 -0
  31. package/docs/roles-and-configuration.zh-CN.md +38 -0
  32. package/docs/storage-baseline.md +4 -1
  33. package/docs/storage-baseline.zh-CN.md +1 -1
  34. package/docs/testing/verification-levels.md +2 -2
  35. package/docs/testing/verification-levels.zh-CN.md +1 -1
  36. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.
3
3
  import { providerRetryPending } from "../runtime/providerRetry.js";
4
4
  /** Existing full reconciliation pass only; no new timer, input or Session. */
5
5
  export async function recoverTaskHostTerminals(home, store, onError, reconcile = reconcileAgentHostTurn) {
6
+ const observations = [];
6
7
  for (const candidate of store.listRuntimeSessionCandidates({ scope: "task" })) {
7
8
  const owner = candidate.owner;
8
9
  if (owner.scope !== "task")
@@ -20,14 +21,24 @@ export async function recoverTaskHostTerminals(home, store, onError, reconcile =
20
21
  attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId,
21
22
  authority: { epoch: binding.authority.epoch, owner: "controller", holderId: binding.authority.holderId }
22
23
  } });
24
+ observations.push({ taskId: owner.taskId, roleName: owner.roleName,
25
+ nativeSessionId: currentProviderConversation(binding).conversationId, attemptId: turn.attemptId,
26
+ state: result.snapshot.state, reconciliation: result.reconciliation ?? null,
27
+ outcome: result.outcome, observedAt: new Date().toISOString() });
23
28
  if (result.outcome === "rejected" || result.reconciliation === "unsupported") {
24
29
  throw new Error(result.failure?.detail
25
30
  ?? "Host terminal reconciliation is unsupported; retain the original input and inspect Host/Inbox evidence.");
26
31
  }
27
32
  }
28
33
  catch (error) {
34
+ if (!observations.some(item => item.taskId === owner.taskId && item.roleName === owner.roleName)) {
35
+ observations.push({ taskId: owner.taskId, roleName: owner.roleName,
36
+ nativeSessionId: currentProviderConversation(binding).conversationId, attemptId: turn.attemptId,
37
+ state: "unobservable", reconciliation: null, outcome: "unavailable", observedAt: new Date().toISOString() });
38
+ }
29
39
  onError(new Error(`Task Host terminal recovery ${owner.taskId}/${owner.roleName}/${turn.attemptId}: `
30
40
  + `${error instanceof Error ? error.message : String(error)} No input was replayed.`, { cause: error }));
31
41
  }
32
42
  }
43
+ return observations;
33
44
  }
@@ -111,6 +111,8 @@ export function recordRoleAgentSession(set, input, now) {
111
111
  adapterId,
112
112
  nativeSessionId,
113
113
  ...optionalSessionText("title", input.title ?? continuing?.title),
114
+ ...((input.generatedTitle ?? continuing?.generatedTitle) === undefined ? {}
115
+ : { generatedTitle: input.generatedTitle ?? continuing?.generatedTitle }),
114
116
  ...optionalSessionText("preview", input.preview ?? continuing?.preview),
115
117
  policy: input.policy,
116
118
  effective: continuing?.effective ?? effective,
@@ -567,6 +569,11 @@ export function validateRoleAgentSession(session, expectedAgentId = session.agen
567
569
  throw new Error(`Role Agent session effective identity is inconsistent: ${agentId}.`);
568
570
  }
569
571
  requireText(session.nativeSessionId, "Native session id");
572
+ if (session.generatedTitle !== undefined
573
+ && (typeof session.generatedTitle !== "string" || session.generatedTitle.length > 80
574
+ || optionalSessionText("title", session.generatedTitle).title !== session.generatedTitle)) {
575
+ throw new Error("Role Agent generated title is invalid.");
576
+ }
570
577
  if (session.title !== undefined
571
578
  && optionalSessionText("title", session.title).title !== session.title) {
572
579
  throw new Error("Role Agent session title is invalid.");
@@ -17,7 +17,7 @@ import { managedRuntimeAdmission } from "../runtime/agentDriver.js";
17
17
  import { builtinAgentEndpointImplementation, validateAgentEndpointImplementation } from "../runtime/agentEndpointIdentity.js";
18
18
  import { builtinAgentDriverRegistry, builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
19
19
  import { assertProviderConversationReplaceable } from "../runtime/providerRuntimeIdentity.js";
20
- import { operatorSessionTitle, resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
20
+ import { globalRoleSessionTitle, resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
21
21
  import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
22
22
  import { planningRuntimeCwd } from "../storage/homeLayout.js";
23
23
  import { resolveAgentAdapter } from "./agentAdapter.js";
@@ -220,7 +220,11 @@ export class FileRoleLaunchPlanner {
220
220
  // attempt was rejected, cancelled, or completed.
221
221
  assertProviderConversationReplaceable(sessionSet.providerBinding);
222
222
  }
223
- return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, {
223
+ return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name, {
224
+ executionAuthority: (input.mode === "resume" ? existing?.effective : effective)?.executionAuthority,
225
+ instanceCode: role.instanceCode,
226
+ generatedTitle: existing?.generatedTitle
227
+ }), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, {
224
228
  purpose
225
229
  });
226
230
  }
@@ -249,9 +253,12 @@ export class FileRoleLaunchPlanner {
249
253
  if (input.mode === "new" && sessionSet?.providerBinding != null) {
250
254
  assertProviderConversationReplaceable(sessionSet.providerBinding);
251
255
  }
252
- return this.#compile(role, input, { scope: "global" }, input.mode === "new" && role.name === "operator"
253
- ? operatorSessionTitle(this.#now(), this.store.getConfig().timeZone)
254
- : undefined, input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, { purpose: "execution" });
256
+ return this.#compile(role, input, { scope: "global" }, input.mode === "new"
257
+ ? globalRoleSessionTitle(role.name, this.#now(), this.store.getConfig().timeZone, role.instanceCode)
258
+ : existing?.generatedTitle !== undefined && existing.title === existing.generatedTitle
259
+ ? globalRoleSessionTitle(role.name, new Date(existing.createdAt), this.store.getConfig().timeZone, role.instanceCode)
260
+ ?? `Yui ${role.name}`
261
+ : undefined, input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, { purpose: "execution" });
255
262
  }
256
263
  #compile(role, input, owner, sessionTitle, knownNativeSessionId, effective, sessionPolicy) {
257
264
  const launchRole = effectiveRoleForLaunch(role, effective);
@@ -385,7 +392,9 @@ export class FileRoleLaunchPlanner {
385
392
  agent,
386
393
  config: effectiveConfig,
387
394
  workspace: agentWorkspace,
388
- ...(sessionTitle === undefined || postCreateOperatorTitle
395
+ // CLI --name has no ownership check on resume. Only the Codex native
396
+ // metadata path below may rename an existing conversation.
397
+ ...(sessionTitle === undefined || postCreateOperatorTitle || input.mode === "resume"
389
398
  ? {}
390
399
  : { sessionTitle }),
391
400
  ...sessionContext
@@ -510,6 +519,9 @@ export class FileRoleLaunchPlanner {
510
519
  mode: "resume",
511
520
  nativeSessionId: requireText(providerNativeSessionId, "Managed Provider restore native session id"),
512
521
  ...(sessionTitle === undefined ? {} : { sessionTitle }),
522
+ ...(existingSession?.generatedTitle !== undefined
523
+ && existingSession.title === existingSession.generatedTitle
524
+ ? { previousSessionTitle: existingSession.generatedTitle } : {}),
513
525
  ...(managedCompiled.codexThread === undefined
514
526
  ? {}
515
527
  : { codexThread: managedCompiled.codexThread }),
@@ -594,6 +606,10 @@ export class FileRoleLaunchPlanner {
594
606
  },
595
607
  launch: ordinaryConversationLaunch,
596
608
  session,
609
+ ...(sessionTitle !== undefined && (input.mode === "new"
610
+ || existingSession?.title === undefined
611
+ || existingSession.title === existingSession.generatedTitle)
612
+ ? { generatedTitle: sessionTitle } : {}),
597
613
  ...(sessionTitle === undefined ? {} : { sessionTitle })
598
614
  };
599
615
  }
@@ -54,6 +54,7 @@ export function renderRoleDetails(title, role, input) {
54
54
  ...(!isEnabled(role) ? [` Availability ${roleDisabledReason(role)}`] : []),
55
55
  ` Active Agent ${role.activeAgentId}`,
56
56
  ` Workspace ${role.workspace}`,
57
+ ` Instance code ${role.instanceCode ?? "-"} (display only; next Session launch)`,
57
58
  ...("taskId" in role ? [
58
59
  ` Desired environment ${role.executionEnvironment === undefined
59
60
  ? "managed workspace"
@@ -0,0 +1,7 @@
1
+ export function validateInstanceCode(value) {
2
+ if (typeof value !== "string" || value.length === 0 || value.length > 24
3
+ || !/^[\p{L}\p{N}\p{M}_-]+$/u.test(value)) {
4
+ throw new Error("Instance code must be 1–24 UTF-16 units of Unicode letters, numbers, marks, '_' or '-'; omit '@'.");
5
+ }
6
+ return value;
7
+ }
package/dist/role/role.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { validateInstanceCode } from "./instanceCode.js";
2
3
  import { assertRoleEnabled, validateEnabled } from "./availability.js";
3
4
  import { requireKnownFields } from "../domain/validation.js";
4
5
  import { jobScopeKey } from "../job/jobDefinition.js";
@@ -49,6 +50,7 @@ export function copyGlobalRoleToTaskRole(globalRole, taskId, now, name = globalR
49
50
  const timestamp = now.toISOString();
50
51
  return validateTaskRole({
51
52
  ...cloneProfile(globalRole),
53
+ ...(globalRole.instanceCode === undefined ? {} : { instanceCode: globalRole.instanceCode }),
52
54
  schemaVersion: 1,
53
55
  enabled: true,
54
56
  launchRevision: 1,
@@ -75,6 +77,8 @@ export function updateGlobalRole(role, patch, now) {
75
77
  updatedAt: now.toISOString()
76
78
  };
77
79
  clearProfileFields(updated, patch);
80
+ if (Object.hasOwn(patch, "instanceCode") && patch.instanceCode === undefined)
81
+ delete updated.instanceCode;
78
82
  updated.launchRevision = isDeepStrictEqual(desiredBefore, desiredLaunchProjection(updated))
79
83
  ? role.launchRevision
80
84
  : role.launchRevision + 1;
@@ -94,6 +98,8 @@ export function updateRole(role, patch, now) {
94
98
  updated.executionEnvironment = cloneJson(patch.executionEnvironment);
95
99
  }
96
100
  clearProfileFields(updated, patch);
101
+ if (Object.hasOwn(patch, "instanceCode") && patch.instanceCode === undefined)
102
+ delete updated.instanceCode;
97
103
  updated.launchRevision = isDeepStrictEqual(desiredBefore, desiredLaunchProjection(updated))
98
104
  ? role.launchRevision
99
105
  : role.launchRevision + 1;
@@ -222,6 +228,7 @@ function createRoleOwner(name, bindings, activeAgentId, workspace, now, profile,
222
228
  const timestamp = now.toISOString();
223
229
  return {
224
230
  ...cloneProfile(profile),
231
+ ...(profile.instanceCode === undefined ? {} : { instanceCode: validateInstanceCode(profile.instanceCode) }),
225
232
  enabled: true,
226
233
  launchRevision: 1,
227
234
  defaultAccess,
@@ -234,6 +241,8 @@ function createRoleOwner(name, bindings, activeAgentId, workspace, now, profile,
234
241
  };
235
242
  }
236
243
  function validateRoleOwner(role) {
244
+ if (role.instanceCode !== undefined)
245
+ validateInstanceCode(role.instanceCode);
237
246
  validateEnabled(role.enabled);
238
247
  if (role.schemaVersion !== 1) {
239
248
  throw new Error("Role schema version is invalid.");
@@ -303,6 +312,7 @@ function assertSessionOwnerMatchesRole(role, sessions) {
303
312
  }
304
313
  function cloneRolePatch(patch) {
305
314
  return {
315
+ ...(patch.instanceCode === undefined ? {} : { instanceCode: validateInstanceCode(patch.instanceCode) }),
306
316
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
307
317
  ...(patch.name === undefined ? {} : { name: requireSafeIdentity(patch.name, "Role name") }),
308
318
  ...(patch.activeAgentId === undefined
@@ -81,6 +81,7 @@ class BuiltinAgentEndpoint {
81
81
  driver;
82
82
  configuration;
83
83
  get nativeAccess() { return this.driver.nativeAccess; }
84
+ get appliedGeneratedTitle() { return this.driver.appliedGeneratedTitle; }
84
85
  get ownedProcessId() { return this.driver.ownedProcessId; }
85
86
  get nativeAccountHome() { return this.driver.nativeAccountHome; }
86
87
  capabilities;
@@ -120,7 +120,13 @@ export async function runAgentHost(input) {
120
120
  const recentSteerAttempts = new Map();
121
121
  await replayExitOutbox(input.home);
122
122
  const updateSnapshot = (next) => {
123
- snapshot = validateSnapshot(next);
123
+ const { appliedGeneratedTitle: _oldTitle, ...rest } = next;
124
+ snapshot = validateSnapshot({
125
+ ...rest,
126
+ ...(session?.appliedGeneratedTitle === undefined ? {} : {
127
+ appliedGeneratedTitle: session.appliedGeneratedTitle
128
+ })
129
+ });
124
130
  };
125
131
  const authorityFields = () => ({
126
132
  ...(session === undefined ? {} : { endpointImplementation: session.configuration.implementation }),
@@ -374,6 +380,8 @@ export async function runAgentHost(input) {
374
380
  && currentPayload.environment.YUI_ROLE === "operator")
375
381
  ? {}
376
382
  : { sessionTitle: previousControl.sessionTitle }),
383
+ ...(previousControl.previousSessionTitle === undefined
384
+ ? {} : { previousSessionTitle: previousControl.previousSessionTitle }),
377
385
  codexThread: disconnectedSession.configuration.threadOptions,
378
386
  endpointImplementation: disconnectedSession.configuration.implementation,
379
387
  ...(ownedTurn === undefined ? {} : { ownedTurn }),
@@ -167,6 +167,9 @@ function validateProviderControl(control) {
167
167
  throw new Error("Agent Host Provider session title is invalid.");
168
168
  }
169
169
  }
170
+ if (control.previousSessionTitle !== undefined) {
171
+ text(control.previousSessionTitle, "previousSessionTitle");
172
+ }
170
173
  validateProviderAuthorityFence(control.authority);
171
174
  }
172
175
  function validateCodexThreadOptions(options) {
@@ -15,6 +15,9 @@ export function createRuntimeBinding(input) {
15
15
  ...(input.nativeSessionId === undefined
16
16
  ? {}
17
17
  : { nativeSessionId: requireText(input.nativeSessionId, "Native session id") }),
18
+ ...(input.appliedGeneratedTitle === undefined ? {} : {
19
+ appliedGeneratedTitle: requireText(input.appliedGeneratedTitle, "Applied generated title")
20
+ }),
18
21
  ...(input.providerAuthority === undefined
19
22
  ? {}
20
23
  : { providerAuthority: validateProviderAuthorityFence(input.providerAuthority) })
@@ -1,29 +1,33 @@
1
1
  import { resolveTimeZone } from "../config/timeZone.js";
2
+ import { validateInstanceCode } from "../role/instanceCode.js";
2
3
  const TITLE_SEPARATOR = " · ";
3
4
  export const MAX_SESSION_TITLE_LENGTH = 80;
4
5
  const TASK_TITLE_MAX_LENGTH = 20;
5
- export function taskRoleSessionTitle(task, roleName) {
6
- const prefix = `Yui ${roleLabel(roleName)} ${normalizeSegment(task.id)}`;
6
+ export function taskRoleSessionTitle(task, roleName, options = {}) {
7
+ const prefix = `Yui ${roleLabel(roleName)} ${normalizeSegment(task.id)}`
8
+ + (roleName === "leader" && options.executionAuthority === "planning" ? " Draft" : "")
9
+ + instanceSegment(options.instanceCode);
7
10
  const title = displayTitle(normalizeSegment(task.title), TASK_TITLE_MAX_LENGTH);
8
11
  const full = `${prefix}${TITLE_SEPARATOR}${title}`;
9
12
  if (full.length <= MAX_SESSION_TITLE_LENGTH)
10
13
  return full;
11
14
  if (prefix.length + TITLE_SEPARATOR.length + 1 > MAX_SESSION_TITLE_LENGTH) {
12
- return truncate(prefix, MAX_SESSION_TITLE_LENGTH);
15
+ throw new Error("Session title identity exceeds the maximum length; shorten the Role name or instance code.");
13
16
  }
14
- const titleLength = MAX_SESSION_TITLE_LENGTH - prefix.length - TITLE_SEPARATOR.length - 1;
17
+ const titleLength = MAX_SESSION_TITLE_LENGTH - prefix.length - TITLE_SEPARATOR.length;
15
18
  return `${prefix}${TITLE_SEPARATOR}${displayTitle(title, Math.max(titleLength, 1))}`;
16
19
  }
17
- export function resolveTaskRoleSessionTitle(existingTitle, task, roleName) {
20
+ export function resolveTaskRoleSessionTitle(existingTitle, task, roleName, options = {}) {
18
21
  if (existingTitle !== undefined
19
22
  && existingTitle.length > 0
20
23
  && existingTitle.length <= MAX_SESSION_TITLE_LENGTH
21
- && !/[\r\n\0]/u.test(existingTitle)) {
24
+ && !/[\r\n\0]/u.test(existingTitle)
25
+ && existingTitle !== options.generatedTitle) {
22
26
  return existingTitle;
23
27
  }
24
- return taskRoleSessionTitle(task, roleName);
28
+ return taskRoleSessionTitle(task, roleName, options);
25
29
  }
26
- export function operatorSessionTitle(now, configuredTimeZone) {
30
+ export function operatorSessionTitle(now, configuredTimeZone, instanceCode) {
27
31
  if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
28
32
  throw new TypeError("Operator session title date is invalid.");
29
33
  }
@@ -35,7 +39,23 @@ export function operatorSessionTitle(now, configuredTimeZone) {
35
39
  if (parts.month === undefined || parts.day === undefined) {
36
40
  throw new TypeError("Operator session title date is unavailable.");
37
41
  }
38
- return `Yui${TITLE_SEPARATOR}Operator${TITLE_SEPARATOR}${parts.month}${parts.day}`;
42
+ const prefix = instanceCode === undefined
43
+ ? `Yui${TITLE_SEPARATOR}Operator`
44
+ : `Yui Operator${instanceSegment(instanceCode)}`;
45
+ return `${prefix}${TITLE_SEPARATOR}${parts.month}${parts.day}`;
46
+ }
47
+ export function globalRoleSessionTitle(roleName, now, configuredTimeZone, instanceCode) {
48
+ if (roleName === "operator")
49
+ return operatorSessionTitle(now, configuredTimeZone, instanceCode);
50
+ if (instanceCode === undefined)
51
+ return undefined;
52
+ const title = `Yui ${roleLabel(roleName)}${instanceSegment(instanceCode)}`;
53
+ if (title.length > MAX_SESSION_TITLE_LENGTH)
54
+ throw new Error("Session title identity exceeds the maximum length.");
55
+ return title;
56
+ }
57
+ function instanceSegment(value) {
58
+ return value === undefined ? "" : ` @${validateInstanceCode(value)}`;
39
59
  }
40
60
  function displayTitle(value, maxLength) {
41
61
  if (value.length <= maxLength)
@@ -384,6 +384,7 @@ class CodexStructuredProviderSession {
384
384
  onActivity;
385
385
  mirror;
386
386
  nativeAccountHome;
387
+ appliedGeneratedTitle;
387
388
  nativeAccess;
388
389
  #turnAttempts = new Map();
389
390
  adapterId = "codex";
@@ -445,12 +446,20 @@ class CodexStructuredProviderSession {
445
446
  throw error;
446
447
  }
447
448
  }
448
- if (control.sessionTitle !== undefined) {
449
+ const threadMetadata = nativeConfiguration.thread;
450
+ const nativeTitle = typeof threadMetadata === "object" && threadMetadata !== null
451
+ && !Array.isArray(threadMetadata) ? threadMetadata.name : undefined;
452
+ // A native rename belongs to the user. Missing metadata is not evidence
453
+ // that an old title is still ours; leave it alone on resume.
454
+ let appliedGeneratedTitle;
455
+ if (control.sessionTitle !== undefined && (control.mode === "new"
456
+ || (control.previousSessionTitle !== undefined && nativeTitle === control.previousSessionTitle))) {
449
457
  try {
450
458
  await runtime.setConversationName({
451
459
  conversationId,
452
460
  name: control.sessionTitle
453
461
  });
462
+ appliedGeneratedTitle = control.sessionTitle;
454
463
  }
455
464
  catch (error) {
456
465
  onDiagnostic?.({
@@ -465,6 +474,7 @@ class CodexStructuredProviderSession {
465
474
  }
466
475
  const session = new CodexStructuredProviderSession(child, exit, processInstanceId, conversationId, runtime, onStarted, onTerminal, onGoal, onInput, onActivity, mirror, optionalId(initialized.codexHome));
467
476
  session.nativeAccess = createCodexNativeAccess(conversationId, initialized, channel, openingMessages, nativeConfiguration);
477
+ session.appliedGeneratedTitle = appliedGeneratedTitle;
468
478
  session.#activeTurnId = resumedActiveTurnId;
469
479
  const ownedTurn = control.kind === "restore" ? control.ownedTurn : undefined;
470
480
  session.#clientOwnedTurnId = ownedTurn?.turnId;
@@ -246,6 +246,7 @@ export class TmuxSessionHost {
246
246
  agentId: request.agentId,
247
247
  adapterId: request.adapterId,
248
248
  effective: request.effective,
249
+ ...(planned.generatedTitle === undefined ? {} : { generatedTitle: planned.generatedTitle }),
249
250
  ...(planned.launch.env.YUI_SESSION_TITLE === undefined
250
251
  ? {}
251
252
  : { sessionTitle: planned.launch.env.YUI_SESSION_TITLE }),
@@ -436,6 +437,9 @@ export class TmuxSessionHost {
436
437
  roleName: request.owner.roleName
437
438
  }),
438
439
  hostCreated,
440
+ ...(providerSnapshot?.appliedGeneratedTitle === undefined ? {} : {
441
+ appliedGeneratedTitle: providerSnapshot.appliedGeneratedTitle
442
+ }),
439
443
  ...((providerSnapshot?.nativeSessionId ?? nativeSessionId) === undefined
440
444
  ? {}
441
445
  : { nativeSessionId: providerSnapshot?.nativeSessionId ?? nativeSessionId }),
@@ -54,6 +54,22 @@ export function queryContextRecords(db, taskId, query) {
54
54
  const from = `${table} r${candidate ? ", json_each(r.payload, '$.candidates') candidate" : ""}`;
55
55
  const where = ["r.task_id = @taskId"];
56
56
  const params = { taskId };
57
+ if (query.eventAttemptId !== undefined) {
58
+ if (query.family !== "task-event")
59
+ throw new Error("Attempt selection requires Events.");
60
+ where.push("json_extract(r.payload, '$.payload.attemptId') = @eventAttemptId");
61
+ params.eventAttemptId = query.eventAttemptId;
62
+ }
63
+ if (query.queuedForRole !== undefined) {
64
+ if (query.family !== "task-message")
65
+ throw new Error("Queued Role selection requires Messages.");
66
+ where.push(`json_extract(r.payload, '$.recipient.roleName') = @queuedRole
67
+ AND json_extract(r.payload, '$.recipient.ownerRunId') IS NOT NULL
68
+ AND json_extract(r.payload, '$.inbox') IS NULL
69
+ AND json_extract(r.payload, '$.continuation.runId') IS NULL
70
+ AND COALESCE(json_extract(r.payload, '$.inputControl.action'), '') != 'steer'`);
71
+ params.queuedRole = query.queuedForRole;
72
+ }
57
73
  if (query.ids !== undefined) {
58
74
  where.push(`${id} IN (SELECT value FROM json_each(@ids))`);
59
75
  params.ids = JSON.stringify(query.ids);
@@ -276,6 +276,16 @@ const MINOR_UPGRADES = Object.freeze([{
276
276
  // No payload rewriting or historical revision invention. A newly opened
277
277
  // reader starts empty; zero is the initial token until the first mutation.
278
278
  sql: TASK_READ_REVISION_SCHEMA_SQL
279
+ }, {
280
+ fromVersion: "1.24",
281
+ toVersion: "1.25",
282
+ name: "role-instance-code-session-titles",
283
+ introducedIn: "next",
284
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
285
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
286
+ // Optional Role presentation and generated-title provenance. Never infer
287
+ // ownership of historical titles, rewrite frozen profiles, or fill defaults.
288
+ sql: ""
279
289
  }]);
280
290
  export function storageMinorUpgradePlan(from) {
281
291
  if (from === CURRENT_STORAGE_VERSION)
@@ -1088,6 +1088,22 @@ export class SqliteTaskStore {
1088
1088
  }
1089
1089
  if (query.operatorNotice)
1090
1090
  where.push("json_extract(payload, '$.inputControl.requestId') LIKE 'operator-notice:%'");
1091
+ if (query.awaitingDelivery)
1092
+ where.push(`(json_extract(payload, '$.delivery') IS NULL
1093
+ OR json_extract(payload, '$.delivery.via') = 'transport')
1094
+ AND json_extract(payload, '$.inbox.ack') IS NULL
1095
+ AND json_extract(payload, '$.inbox.retirement') IS NULL
1096
+ AND (json_extract(payload, '$.inputControl.action') = 'queue'
1097
+ OR json_extract(payload, '$.interruptThen') IS NOT NULL
1098
+ OR json_extract(payload, '$.control.outcome') IN ('pending','delivery-unknown'))`);
1099
+ if (query.excludeHealthNotices)
1100
+ where.push("COALESCE(json_extract(payload, '$.inputControl.requestId'), '') NOT LIKE 'operator-notice:message-health:%'");
1101
+ if (query.systemBodyPrefix !== undefined) {
1102
+ where.push(`json_extract(payload, '$.kind') = 'system'
1103
+ AND json_extract(payload, '$.author.type') = 'system'
1104
+ AND substr(json_extract(payload, '$.body'), 1, length(@bodyPrefix)) = @bodyPrefix`);
1105
+ params.bodyPrefix = query.systemBodyPrefix;
1106
+ }
1091
1107
  if (query.afterSequence !== undefined) {
1092
1108
  where.push("seq > @after");
1093
1109
  params.after = query.afterSequence;
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.24";
1
+ export const CURRENT_STORAGE_VERSION = "1.25";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -643,3 +643,99 @@ Operator storage diagnosis/upgrade also reaches the dedicated migration entry
643
643
  without first requiring the Home to already have the target storage version.
644
644
  Completion, cancellation and archive have distinct authority and resource
645
645
  boundaries. Storage changes use the single [upgrade contract](sqlite-control-plane-design.md).
646
+
647
+ ## Message delivery health
648
+
649
+ The Controller's existing full reconciliation observes Task Role notification
650
+ mailboxes, Assignment continuations, Global Role input and the Operator attention
651
+ mailbox. It reads the real `pending` suffix separately from `processing`, never
652
+ the compatibility `getPendingWakeup` projection. It does not run an Agent polling
653
+ loop, acknowledge input, release a claim, resend an uncertain input or start an
654
+ unavailable Operator.
655
+
656
+ `yui controller status --verbose --json` exposes a cached, timestamped
657
+ `identity.runtime.observations.messageHealth` view (the Controller RPC exposes
658
+ `runtime.messageHealth`). It includes counts, oldest wait/occupancy, exact
659
+ Session/attempt/wake/message references, answered input references, recorded
660
+ delivery outcome, retry ownership and the existing Host reconciliation evidence.
661
+ `unobserved` is not proof of Host health. Inspect its exact Event/Message read
662
+ commands for durable originals. Status reads perform no database scans or probes.
663
+ The view shows at most 64 owners and reports omissions; records remain readable
664
+ through the existing Task event and Global Role message commands after restart.
665
+
666
+ An eligible stalled delivery warns after 5 minutes and escalates once at
667
+ 30 minutes, or after 15 minutes if the queue at least doubles and reaches eight
668
+ items. A single old input is sufficient. Sampling is on the configured full-pass
669
+ interval (default 120 seconds, permitted 5–300 seconds): with a responsive
670
+ Controller, detection occurs on the first completed pass after the threshold.
671
+ These are not wall-clock delivery guarantees during downtime, blocked I/O or
672
+ unavailable Providers. Accepted long Turns without contrary exact Host evidence
673
+ are busy, not faults; explicit stops, disabled Roles, human ownership, native
674
+ pause and matching open input waits are distinct from blocked delivery. Valid
675
+ bounded retry remains owned by its existing recovery mechanism. Historical
676
+ missing ACKs without a new waiting suffix do not generate backlog alerts.
677
+
678
+ Each affected delivery owner has one open diagnostic episode: at most a warning,
679
+ one escalation and a backlog-clear observation. Concurrent arrivals coalesce;
680
+ they do not each call a model. Task evidence uses ordinary `message.health.<role>`
681
+ Events; Global evidence uses record-only system Messages. These are append-only
682
+ content in the existing open Event/Message formats, not a new storage envelope,
683
+ schema version or scheduling aggregate. The newest evidence reconstructs
684
+ notification deduplication after restart. Each notice reuses the ordinary
685
+ Operator Global Message queue with a stable request ID, independent of the
686
+ affected Task's occupied mailbox. Only one unsent engineering notice is admitted;
687
+ later evidence stays durable and is reconsidered on subsequent full passes.
688
+
689
+ If Operator is absent/stopped/uncontrolled, evidence remains generated but no
690
+ new Session is started. If it is busy, the notice stays saved; failures retain
691
+ their exact message and reason and are not replayed. Diagnostics distinguish
692
+ generated evidence, saved notice, transport acceptance, Provider delivery and
693
+ explicit handling. An alert ACK is never recovery. Only an observed empty
694
+ delivery backlog closes an episode; an explicit pause or merely releasing the
695
+ old processing claim while a pending suffix remains does not. Backlog clearance
696
+ itself proves neither business handling nor completion, and grants no recovery
697
+ authority. Moving all pending work to a new processing owner is not clearance:
698
+ retained processing with a deferred delivery remains observable, including its
699
+ exact attempt and rejection detail, even when the current Turn is completed.
700
+
701
+ ### Management recovery guidance
702
+
703
+ Notices include the current observation time, exact identities, original read
704
+ commands and conditional recovery guidance, also available in the cached
705
+ `recovery` field. A notice delayed by Operator unavailability uses current facts
706
+ when it is finally saved, not the original incident's intervention advice.
707
+ Already-saved notices remain immutable evidence and are not resent merely to
708
+ update advice: always refresh status and exact Session/wake/message evidence
709
+ before acting. A command template is neither authorization nor proof that a
710
+ later invocation still targets the observed Session; current CLI fences apply.
711
+
712
+ An affected Leader is supervised by Operator; a subordinate Task Role by its
713
+ authorized Leader or Operator. Normal busy work, legitimate pause and owned
714
+ bounded retry receive non-intervention guidance. For an established retained
715
+ Task Host barrier, the supervisor may consider the existing targeted
716
+ `task role session stop` path without waiting for a complete engineering repair
717
+ or perfect historical projections. Inspect exact native/background execution,
718
+ event persistence, authority and cleanup consequences first. Unpersisted Host
719
+ facts must not be destroyed. A saved stop request is not successful physical
720
+ cleanup; retain failure evidence when cleanup cannot be proven. Existing
721
+ authorized lifecycle/delivery may reuse the retained conversation after proven
722
+ stop; verify its identity. Deliberate `task role session new` is a different,
723
+ explicit replacement decision, requiring durable progress and fenced cleanup.
724
+ Goal `stalled` alone neither establishes the cause nor warrants clearing Goal.
725
+
726
+ In this candidate, `task wake retry` requires a proven-rejected, unconsumed
727
+ exact claim, a corrected cause and no accepted/uncertain native execution.
728
+ `task wake resolve` supports an unknown exact claim after shared-native
729
+ quiescence; it releases scheduling without replay or semantic acknowledgement.
730
+ Deferred delivery meets neither precondition. Do not assume another recovery
731
+ package's extended behavior is installed. For Global Roles, this candidate
732
+ does not establish a dedicated targeted Host-stop command: inspect original
733
+ input/control and `session retry <role> show`, then discover current authorized
734
+ capabilities. There is no guessed Task equivalent or stop-all fallback.
735
+
736
+ On refusal or failure, preserve the exact receipt, original inputs and work,
737
+ and escalate only the missing authority, capability or cleanup evidence.
738
+ Verify management request, physical stop, conversation reuse/replacement,
739
+ actual subsequent Provider acceptance and business handling separately.
740
+ Missing evidence remains unverified; no automatic recovery, replay, forced
741
+ ACK, model/account/permission change or Controller-wide restart is introduced.
@@ -480,3 +480,68 @@ Task。Operator 诊断和 Controller 重启不先要求一个健康的 Controlle
480
480
  也能到达专用的迁移入口,而不先要求 Home 已经处于目标存储版本。完成、取消和归档具有
481
481
  彼此不同的权限和资源边界。存储变更使用唯一的
482
482
  [升级合同](sqlite-control-plane-design.zh-CN.md)。
483
+
484
+ ## 消息投递健康度
485
+
486
+ Controller 在既有 full reconciliation 中观察 Task Role 通知 mailbox、
487
+ Assignment continuation、Global Role 输入及 Operator attention mailbox。
488
+ 分别读取真实 `pending` 后缀与 `processing`,不把 `getPendingWakeup` 的兼容投影
489
+ 当作底层队列;不启动 Agent 私有轮询、不自动 ACK、释放占用或重放未知输入。
490
+
491
+ `yui controller status --verbose --json` 的
492
+ `identity.runtime.observations.messageHealth` 提供带采样时间的缓存诊断;Controller RPC 对应
493
+ `runtime.messageHealth`。可查看数量、最老等待与占用时长、精确 Session/attempt/wake/
494
+ message、已回答输入、投递证据、重试归属与既有 Host 核对结果。`unobserved` 不代表
495
+ Host 健康。按其中的 Event/Message 读取命令核对持久原始证据。状态读取不会扫描数据库
496
+ 或发起探测;最多展示 64 个 owner 并显式报告省略数量,重启后原始记录仍可读取。
497
+
498
+ 符合异常条件的投递等待在 5 分钟后预警,30 分钟后最多升级一次;若队列至少翻倍且达到
499
+ 8 条,可在 15 分钟后提前升级。仅一条长期不推进的输入也会检测。采样复用 full-pass
500
+ 间隔(默认 120 秒,可设 5–300 秒),健康响应的 Controller 在超过阈值后的首次完整
501
+ 检查发现;停机、I/O 堵塞和 Provider 不可用期间不保证墙钟时限或即时送达。没有精确
502
+ Host 反证的 accepted 长 Turn 视为忙碌;显式停止、Role 禁用、human 所有权、原生
503
+ 暂停、匹配的未回答输入等待单独区分。有效的有界重试仍由既有恢复机制拥有。只有历史
504
+ 缺 ACK、没有新待投递后缀,不会生成积压告警。
505
+
506
+ 按受影响的投递 owner 合并一个未关闭异常,最多保存预警、升级和积压消除三个阶段,
507
+ 并发到达不会逐条调用模型。Task 使用普通 `message.health.<role>` Event,Global
508
+ 使用 record-only system Message,都是既有开放内容格式中的追加证据,不新增存储
509
+ envelope、schema 或调度权威。最新证据用于重启后的去重。告警使用稳定 request ID,
510
+ 走独立于受影响 Task 占用的 Operator Global Message 通道;最多保留一条未发送的
511
+ 工程通知,其余待告警证据持久保留并在后续 full pass 检查。
512
+
513
+ Operator 缺席、已停止或没有受控 binding 时,不自动创建 Session;忙碌时保留已保存
514
+ 通知,失败时保留原 Message 和原因、不重放。诊断区分已生成证据、已保存通知、传输
515
+ 接受、Provider 送达与显式处置。告警 ACK 不关闭异常。只有观察到投递积压消失才记录
516
+ 恢复;显式暂停、或仅释放旧 processing 但 pending 仍在,不算恢复。积压消除也不证明
517
+ 业务处理完成、Task 验收或获得任何额外恢复权限。
518
+ pending 全部转入新 processing owner 也不算清除:即便当前 Turn 已完成,持续 deferred
519
+ 的 processing 仍保留监测及精确 attempt、投递拒绝详情。
520
+
521
+ ### 管理恢复引导
522
+
523
+ 通知直接包含当前观测时间、精确身份、原始读取命令及有前提的恢复引导,缓存诊断的
524
+ `recovery` 字段也提供同一引导。Operator 不可用导致延迟保存的通知使用保存当时的
525
+ 当前观测,不复制旧异常的操作建议;已保存通知不重写,也不为更新建议重复发送。
526
+ 执行前必须刷新 status 和准确 Session/wake/message 原始证据。命令模板不是授权,
527
+ 也不能固定未来调用的目标身份;实际操作仍受现有 CLI 围栏约束。
528
+
529
+ Leader 自身受阻由 Operator 处理,下属 Task Role 由有权限的 Leader 或 Operator
530
+ 处理。正常忙碌、合法暂停、有界重试仍拥有恢复时,不推荐打断。确认 Task Host 遗留
531
+ 占用后,可评估已有准确 `task role session stop` 管理路径,不必等待完整工程修复包
532
+ 或所有历史投影对齐;先核对准确原生/后台执行、事件持久化、权限和清理影响,不能丢失
533
+ Host 中尚未持久化的事实。stop 请求保存不等于物理清理成功,失败时保留原证据。
534
+ 证明停止后,既有授权生命周期/投递可在支持时复用原 conversation,须验证实际身份。
535
+ `task role session new` 则是另行明确选择新 Session,需保存进展并通过清理围栏。
536
+ 仅显示 Goal stalled 不能确定根因,也不能据此要求清 Goal。
537
+
538
+ 本候选的 `task wake retry` 仅适用已纠正原因、原准确 claim 仍未消费的明确 rejected,
539
+ 且不能存在已接受或不确定原生执行;`task wake resolve` 仅支持准确 unknown claim
540
+ 在共享原生静止证明后释放调度,不重放、不 ACK。deferred 不满足二者前提,不能假定
541
+ 其他恢复包的扩展行为已经安装。GlobalRole 未确认有专用准确 Host-stop 命令时,读取
542
+ 原输入/control 和 `session retry <role> show`,再发现当前权限下的能力;不臆造
543
+ Task 等价命令,也不退化为 stop-all。
544
+
545
+ 动作拒绝或失败后,保留精确回执、原输入和工作区,只升级缺失的权限、能力或清理证据。
546
+ 分别验证管理请求、物理停止、原会话复用/显式替换、后续实际 Provider 接受及业务处置;
547
+ 缺证据仍标为未验证。不新增自动恢复、重放、强制 ACK、模型/账号/权限变更或全局重启。