@zq-silk/yui 0.8.1 → 0.8.3

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 (52) hide show
  1. package/ARCHITECTURE.md +27 -28
  2. package/README.md +57 -46
  3. package/dist/cli/commandCatalog.js +57 -17
  4. package/dist/cli/interactionPolicy.js +4 -10
  5. package/dist/cli/invocationRouter.js +2 -1
  6. package/dist/cli.js +106 -27
  7. package/dist/commands/taskCommands.js +458 -77
  8. package/dist/commands/taskCompletionGate.js +152 -0
  9. package/dist/context/runContextPack.js +19 -4
  10. package/dist/context/sessionBootstrapManifest.js +1 -1
  11. package/dist/controller/fileSchedulerStoreAdapter.js +389 -70
  12. package/dist/controller/resourceInventory.js +9 -5
  13. package/dist/controller/runtime.js +80 -7
  14. package/dist/controller/runtimeLaunchCoordinator.js +18 -78
  15. package/dist/controller/structuredProviderObservation.js +273 -0
  16. package/dist/executor/agentAdapter.js +40 -0
  17. package/dist/executor/agentExecutor.js +31 -7
  18. package/dist/executor/executorRegistry.js +11 -49
  19. package/dist/executor/fileRoleLaunchPlanner.js +115 -37
  20. package/dist/lifecycle/canonicalLifecycleEvent.js +5 -2
  21. package/dist/repository/gitWorkspace.js +7 -4
  22. package/dist/repository/taskBaseFreshness.js +4 -2
  23. package/dist/run/agentRun.js +4 -4
  24. package/dist/runtime/agentHost.js +767 -158
  25. package/dist/runtime/builtinAgentDrivers.js +1 -5
  26. package/dist/runtime/codexAppServerRuntime.js +67 -60
  27. package/dist/runtime/exactControlPlane.js +7 -2
  28. package/dist/runtime/index.js +6 -2
  29. package/dist/runtime/launchBroker.js +30 -8
  30. package/dist/runtime/providerAuthorityFence.js +24 -0
  31. package/dist/runtime/providerControl.js +63 -0
  32. package/dist/runtime/providerRecoveryDecision.js +55 -0
  33. package/dist/runtime/providerRuntimeIdentity.js +269 -19
  34. package/dist/runtime/runtimeBinding.js +20 -11
  35. package/dist/runtime/structuredProviderHost.js +476 -0
  36. package/dist/runtime/tmuxAdapters.js +143 -42
  37. package/dist/scheduler/activeRoleRunDelivery.js +206 -120
  38. package/dist/scheduler/leaderWakeupProcessor.js +141 -16
  39. package/dist/scheduler/wakeReason.js +1 -0
  40. package/dist/storage/migration/productionRegistry.js +111 -0
  41. package/dist/storage/sqliteStore.js +2 -0
  42. package/dist/storage/taskStore.js +3 -1
  43. package/dist/task/completionReadiness.js +43 -0
  44. package/dist/task/nextAction.js +6 -4
  45. package/dist/task/publicationReference.js +1 -0
  46. package/dist/tmux/tmuxManager.js +1 -1
  47. package/dist/workItem/workItem.js +12 -0
  48. package/dist/workspace/workItemChangeSetManager.js +2 -1
  49. package/i18n/README.zh-CN.md +11 -8
  50. package/package.json +1 -1
  51. package/skills/yui-leader/SKILL.md +8 -3
  52. package/skills/yui-runtime/SKILL.md +7 -2
@@ -67,6 +67,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
67
67
  let effectiveSession = existingSession;
68
68
  let claimed = false;
69
69
  let deliveryAttempted = false;
70
+ let providerSubmissionBegun = false;
70
71
  let run = null;
71
72
  let prepared;
72
73
  let preStartFencePersisted = false;
@@ -183,13 +184,14 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
183
184
  preStartFencePersisted = true;
184
185
  }
185
186
  });
186
- // A managed host may already have submitted the exact Run prompt as
187
- // part of process launch. Once preparation returns that transport
187
+ // A managed Host may have submitted the exact Run Turn after its
188
+ // two-phase launch handshake. Once preparation returns that transport
188
189
  // fact, any
189
190
  // later readiness or aggregate-write failure is delivery uncertainty,
190
191
  // not a launch failure: preserve the Run and its reservation for the
191
192
  // matching provider Hook instead of terminalizing it.
192
- deliveryAttempted = prepared.inputSubmittedAtLaunch === true;
193
+ deliveryAttempted = prepared.turnAcceptedDuringLaunch === true
194
+ || prepared.turnDeliveryUnknownDuringLaunch === true;
193
195
  // Persist the exact preparation fence before waiting on provider
194
196
  // readiness. A pre-input lifecycle Hook may fire during that wait; it
195
197
  // must be able to resolve this Run/Session/launch generation from durable
@@ -211,7 +213,8 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
211
213
  }
212
214
  const ready = await delivery.waitUntilReady(prepared);
213
215
  deliveryAttempted = deliveryAttempted
214
- || ready.prepared.inputSubmittedAtLaunch === true;
216
+ || ready.prepared.turnAcceptedDuringLaunch === true
217
+ || ready.prepared.turnDeliveryUnknownDuringLaunch === true;
215
218
  const latestTask = store.getTask(task.id);
216
219
  if (latestTask === null || latestTask.status !== "active") {
217
220
  delivery.forgetPrepared?.({
@@ -236,12 +239,54 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
236
239
  : { launchId: ready.prepared.launchId }),
237
240
  now
238
241
  });
239
- if (ready.prepared.inputSubmittedAtLaunch === true) {
240
- // A fresh Codex command may carry the exact first prompt in its launch
241
- // argv. That is transport evidence only: the matching Provider Hook
242
- // still owns Run acceptance. Persist the push fence without writing a
243
- // second terminal prompt, then leave the reservation for the async
244
- // SessionStart/UserPromptSubmit fold.
242
+ if (ready.prepared.turnRejectedDuringLaunch === true) {
243
+ delivery.forgetPrepared?.({
244
+ taskId: task.id,
245
+ roleName: role.name,
246
+ runId: run.id,
247
+ ...(ready.prepared.launchId === undefined
248
+ ? {}
249
+ : { launchId: ready.prepared.launchId })
250
+ });
251
+ results.push({
252
+ taskId: task.id,
253
+ runId: run.id,
254
+ status: "skipped",
255
+ reason: "not-ready"
256
+ });
257
+ continue;
258
+ }
259
+ if (ready.prepared.turnDeliveryUnknownDuringLaunch === true) {
260
+ store.saveRoleRunDelivery({
261
+ task,
262
+ role,
263
+ run,
264
+ session: effectiveSession,
265
+ ...(ready.prepared.launchId === undefined
266
+ ? {}
267
+ : { launchId: ready.prepared.launchId }),
268
+ now
269
+ });
270
+ delivery.forgetPrepared?.({
271
+ taskId: task.id,
272
+ roleName: role.name,
273
+ runId: run.id,
274
+ ...(ready.prepared.launchId === undefined
275
+ ? {}
276
+ : { launchId: ready.prepared.launchId })
277
+ });
278
+ results.push({
279
+ taskId: task.id,
280
+ runId: run.id,
281
+ status: "skipped",
282
+ reason: "delivery-uncertain"
283
+ });
284
+ continue;
285
+ }
286
+ if (ready.prepared.turnAcceptedDuringLaunch === true) {
287
+ // The Agent Host submitted the exact first Turn through structured
288
+ // Provider control. Persist the transport fence without a second write;
289
+ // the matching structured acknowledgement owns Run acceptance.
245
290
  store.saveRoleRunDelivery({
246
291
  task,
247
292
  role,
@@ -263,13 +308,41 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
263
308
  results.push({ taskId: task.id, runId: run.id, status: "dispatched" });
264
309
  continue;
265
310
  }
311
+ const receiptId = formatAgentRunReceiptId(task.id, run.id);
312
+ if (effectiveSession === null || effectiveSession.launchId === undefined
313
+ || !hasNativeSession(effectiveSession)
314
+ || store.beginRoleRunProviderTurn?.({
315
+ taskId: task.id,
316
+ roleName: role.name,
317
+ runId: run.id,
318
+ agentId: run.effective.agentId,
319
+ launchId: effectiveSession.launchId,
320
+ nativeSessionId: effectiveSession.nativeSessionId,
321
+ attemptId: receiptId,
322
+ now
323
+ }) !== true) {
324
+ throw new Error("Provider Turn intent could not be durably fenced before delivery.");
325
+ }
326
+ providerSubmissionBegun = true;
266
327
  deliveryAttempted = true;
267
328
  const outcome = await delivery.sendOnce({
268
329
  delivery: ready,
269
- receiptId: formatAgentRunReceiptId(task.id, run.id),
330
+ receiptId,
270
331
  text: serializeRunBootstrapEnvelope(run.bootstrapEnvelope)
271
332
  });
272
333
  if (outcome === "busy" || outcome === "unavailable") {
334
+ store.resolveRoleRunProviderSubmission?.({
335
+ taskId: task.id,
336
+ roleName: role.name,
337
+ runId: run.id,
338
+ attemptId: receiptId,
339
+ status: "rejected",
340
+ reason: outcome === "busy"
341
+ ? "Agent Host was busy before Provider mutation."
342
+ : "Agent Host was unavailable before Provider mutation.",
343
+ now
344
+ });
345
+ providerSubmissionBegun = false;
273
346
  results.push({
274
347
  taskId: task.id,
275
348
  runId: run.id,
@@ -278,6 +351,49 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
278
351
  });
279
352
  continue;
280
353
  }
354
+ if (outcome === "rejected") {
355
+ store.resolveRoleRunProviderSubmission?.({
356
+ taskId: task.id,
357
+ roleName: role.name,
358
+ runId: run.id,
359
+ attemptId: receiptId,
360
+ status: "rejected",
361
+ reason: "Provider returned an exact negative acknowledgement.",
362
+ now
363
+ });
364
+ providerSubmissionBegun = false;
365
+ results.push({ taskId: task.id, runId: run.id, status: "skipped", reason: "not-ready" });
366
+ continue;
367
+ }
368
+ if (outcome === "delivery-unknown") {
369
+ store.resolveRoleRunProviderSubmission?.({
370
+ taskId: task.id,
371
+ roleName: role.name,
372
+ runId: run.id,
373
+ attemptId: receiptId,
374
+ status: "delivery-unknown",
375
+ reason: "Provider Turn delivery is ambiguous; automatic retry is fenced.",
376
+ now
377
+ });
378
+ providerSubmissionBegun = false;
379
+ store.saveRoleRunDelivery({
380
+ task,
381
+ role,
382
+ run,
383
+ session: effectiveSession,
384
+ ...(ready.prepared.launchId === undefined
385
+ ? {}
386
+ : { launchId: ready.prepared.launchId }),
387
+ now
388
+ });
389
+ results.push({
390
+ taskId: task.id,
391
+ runId: run.id,
392
+ status: "skipped",
393
+ reason: "delivery-uncertain"
394
+ });
395
+ continue;
396
+ }
281
397
  store.saveRoleRunDelivery({
282
398
  task,
283
399
  role,
@@ -293,6 +409,17 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
293
409
  catch (error) {
294
410
  const detail = error instanceof Error ? error.message : String(error);
295
411
  const message = `Leader dispatch failed: ${detail}`;
412
+ if (providerSubmissionBegun && run !== null) {
413
+ store.resolveRoleRunProviderSubmission?.({
414
+ taskId: task.id,
415
+ roleName: role.name,
416
+ runId: run.id,
417
+ attemptId: formatAgentRunReceiptId(task.id, run.id),
418
+ status: "delivery-unknown",
419
+ reason: detail,
420
+ now
421
+ });
422
+ }
296
423
  if (claimed && run !== null) {
297
424
  // Scheduler single-flight backpressure: the Role runtime lifecycle
298
425
  // lane was busy when the launch was reserved. The claimed Run stays
@@ -314,11 +441,9 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
314
441
  });
315
442
  continue;
316
443
  }
317
- // A finite managed provider from the preceding Run may still be
318
- // exiting after its durable yield. Keep this newly-claimed Run as the
319
- // sole owner of the Role mailbox; active-Run delivery will retry it
320
- // after the old host disappears. This is runtime backpressure, not a
321
- // Leader failure and not grounds for allocating another Run.
444
+ // A managed Provider lifecycle operation may still be settling. Keep
445
+ // this newly-claimed Run as the sole mailbox owner; active-Run delivery
446
+ // retries after the lifecycle lane clears.
322
447
  if (error instanceof RuntimeLaunchError && error.retryable) {
323
448
  results.push({
324
449
  taskId: task.id,
@@ -22,6 +22,7 @@ export const WAKE_REASON_KINDS = Object.freeze([
22
22
  "leader-run-failed",
23
23
  "role-run-failed",
24
24
  "job-finished",
25
+ "published-tree-authorized",
25
26
  "force-wake"
26
27
  ]);
27
28
  const IMMEDIATE_KINDS = new Set([
@@ -79,6 +79,8 @@ const WORK_MAILBOX_FROM_VERSION = 1;
79
79
  const WORK_MAILBOX_TO_VERSION = 2;
80
80
  const TASK_ROLE_SESSION_SET_FROM_VERSION = 4;
81
81
  const TASK_ROLE_SESSION_SET_TO_VERSION = 5;
82
+ const STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION = 5;
83
+ const STRUCTURED_PROVIDER_SESSION_SET_TO_VERSION = 6;
82
84
  const PUBLICATION_REFERENCE_FROM_VERSION = 0;
83
85
  const PUBLICATION_REFERENCE_TO_VERSION = 1;
84
86
  const CONFIG_FROM_VERSION = 1;
@@ -161,6 +163,7 @@ export function createProductionStorageRegistry() {
161
163
  .registerOfflineMigration(releaseWorkflowIntroductionStep())
162
164
  .registerOfflineMigration(workMailboxV2Step())
163
165
  .registerOfflineMigration(taskRoleSessionSetV5Step())
166
+ .registerOfflineMigration(structuredProviderSessionSetV6Step())
164
167
  .registerOfflineMigration(publicationReferenceIntroductionStep());
165
168
  assertRegistryCoversBaselineToCurrent(registry);
166
169
  return registry;
@@ -2225,6 +2228,114 @@ function taskRoleSessionSetV5Step() {
2225
2228
  declaredEffects: []
2226
2229
  };
2227
2230
  }
2231
+ /**
2232
+ * v6 is a deliberate runtime cutover, not an emulation layer. Existing Task,
2233
+ * Run, Conversation, and Session identities remain as audit evidence, while
2234
+ * every pre-v6 managed process is terminalized locally. A new Agent Host must
2235
+ * establish structured Provider evidence before any further write.
2236
+ */
2237
+ function structuredProviderSessionSetV6Step() {
2238
+ return {
2239
+ axis: "record",
2240
+ recordKind: "taskRoleSessionSet",
2241
+ fromVersion: STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION,
2242
+ toVersion: STRUCTURED_PROVIDER_SESSION_SET_TO_VERSION,
2243
+ preconditions: requireTaskRoleSessionSetV5Family,
2244
+ transform: migrateTaskRoleSessionSetV5ToV6,
2245
+ declaredEffects: []
2246
+ };
2247
+ }
2248
+ function requireTaskRoleSessionSetV5Family(snapshot) {
2249
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2250
+ if (versions.taskRoleSessionSet !== STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION) {
2251
+ throw new Error(`Record taskRoleSessionSet migration requires manifest version ${STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION}.`);
2252
+ }
2253
+ if (snapshot.state === null)
2254
+ return;
2255
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
2256
+ for (const [taskId, rawAggregate] of Object.entries(tasks)) {
2257
+ const aggregate = asObject(rawAggregate, `Task aggregate ${taskId}`);
2258
+ const sets = asObject(aggregate.roleSessionSets, `Task Role session sets ${taskId}`);
2259
+ for (const [roleName, rawSet] of Object.entries(sets)) {
2260
+ const set = asObject(rawSet, `Task Role session set ${taskId}/${roleName}`);
2261
+ if (set.schemaVersion !== STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION) {
2262
+ throw new Error(`Task Role session set ${taskId}/${roleName} must use schemaVersion ${STRUCTURED_PROVIDER_SESSION_SET_FROM_VERSION}.`);
2263
+ }
2264
+ }
2265
+ }
2266
+ }
2267
+ function migrateTaskRoleSessionSetV5ToV6(snapshot) {
2268
+ requireTaskRoleSessionSetV5Family(snapshot);
2269
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2270
+ const schemaManifest = {
2271
+ ...snapshot.schemaManifest,
2272
+ recordVersions: {
2273
+ ...versions,
2274
+ taskRoleSessionSet: STRUCTURED_PROVIDER_SESSION_SET_TO_VERSION
2275
+ }
2276
+ };
2277
+ if (snapshot.state === null)
2278
+ return { schemaManifest, state: null };
2279
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
2280
+ const nextTasks = {};
2281
+ for (const [taskId, rawAggregate] of Object.entries(tasks)) {
2282
+ const aggregate = asObject(rawAggregate, `Task aggregate ${taskId}`);
2283
+ const rawSets = asObject(aggregate.roleSessionSets, `Task Role session sets ${taskId}`);
2284
+ const nextSets = {};
2285
+ for (const [roleName, rawSet] of Object.entries(rawSets)) {
2286
+ const set = asObject(rawSet, `Task Role session set ${taskId}/${roleName}`);
2287
+ const invalidatedAt = String(set.updatedAt);
2288
+ const sessions = asObject(set.sessions, `Task Role sessions ${taskId}/${roleName}`);
2289
+ const nextSessions = Object.fromEntries(Object.entries(sessions).map(([agentId, rawSession]) => {
2290
+ const session = asObject(rawSession, `Task Role Session ${taskId}/${roleName}/${agentId}`);
2291
+ return [agentId, session.status === "stopped" || session.status === "broken"
2292
+ ? session
2293
+ : { ...session, status: "broken", updatedAt: invalidatedAt }];
2294
+ }));
2295
+ nextSets[roleName] = {
2296
+ ...set,
2297
+ schemaVersion: STRUCTURED_PROVIDER_SESSION_SET_TO_VERSION,
2298
+ sessions: nextSessions,
2299
+ providerBinding: set.providerBinding === null
2300
+ ? null
2301
+ : invalidateLegacyProviderBinding(asObject(set.providerBinding, `Provider Binding ${taskId}/${roleName}`), invalidatedAt)
2302
+ };
2303
+ }
2304
+ nextTasks[taskId] = { ...aggregate, roleSessionSets: nextSets };
2305
+ }
2306
+ return { schemaManifest, state: { ...snapshot.state, tasks: nextTasks } };
2307
+ }
2308
+ function invalidateLegacyProviderBinding(binding, invalidatedAt) {
2309
+ if (binding.schemaVersion !== 1) {
2310
+ throw new Error("Pre-v6 Provider Runtime Binding must use schemaVersion 1.");
2311
+ }
2312
+ const activations = Array.isArray(binding.activations) ? binding.activations : [];
2313
+ return {
2314
+ ...binding,
2315
+ schemaVersion: 2,
2316
+ conversations: (Array.isArray(binding.conversations) ? binding.conversations : []).map((rawConversation) => {
2317
+ const conversation = asObject(rawConversation, "Provider Conversation");
2318
+ return conversation.status === "current"
2319
+ ? { ...conversation, recoverability: "unknown" }
2320
+ : conversation;
2321
+ }),
2322
+ activations: activations.map((rawActivation) => {
2323
+ const activation = asObject(rawActivation, "Provider Activation");
2324
+ const { writerLease: _removed, ...withoutLease } = activation;
2325
+ void _removed;
2326
+ return activation.status === "active"
2327
+ ? {
2328
+ ...withoutLease,
2329
+ status: "failed",
2330
+ endedAt: invalidatedAt,
2331
+ terminalReason: "legacy-managed-runtime-invalidated"
2332
+ }
2333
+ : withoutLease;
2334
+ }),
2335
+ authority: { epoch: 1, owner: "none", changedAt: invalidatedAt },
2336
+ turn: null
2337
+ };
2338
+ }
2228
2339
  function requireTaskRoleSessionSetV4Family(snapshot) {
2229
2340
  const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2230
2341
  if (versions.taskRoleSessionSet !== TASK_ROLE_SESSION_SET_FROM_VERSION) {
@@ -732,6 +732,8 @@ export class SqliteTaskStore {
732
732
  return null;
733
733
  return {
734
734
  ...base,
735
+ agentRuns: this.listAgentRuns(taskId),
736
+ roleSessionSets: this.listRoleSessionSets(taskId),
735
737
  managedWorkspaces: this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner)),
736
738
  durableJobs: this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id),
737
739
  integrationQueueEntries: this.#sortById(this.#listPayload("integration_queue", "task_id = ?", [taskId]), (entry) => entry.id),
@@ -117,7 +117,7 @@ export const CURRENT_STORED_TASK_SCHEMA_VERSION = 17;
117
117
  * Keep these named at the storage boundary so the upgrade record-axis map can
118
118
  * assert it is classifying the same bytes the store reads and writes.
119
119
  */
120
- export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 5;
120
+ export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 6;
121
121
  /**
122
122
  * v7 combines optional Issue 04 retry/receipt fields and Issue 05 Leader
123
123
  * actionability fields. All are optional, so the v6→v7 migration is a
@@ -442,6 +442,8 @@ export class FileTaskStore {
442
442
  }
443
443
  return {
444
444
  ...base,
445
+ agentRuns: this.listAgentRuns(taskId),
446
+ roleSessionSets: this.listRoleSessionSets(taskId),
445
447
  managedWorkspaces: values(aggregate.managedWorkspaces, (workspace) => managedWorkspaceKey(workspace.owner)),
446
448
  durableJobs: values(aggregate.durableJobs, "id"),
447
449
  integrationQueueEntries: values(aggregate.integrationQueue, "id"),
@@ -142,6 +142,8 @@ export function projectCompletionReadiness(facts, options = {}) {
142
142
  }
143
143
  // Provider continuations that may still write the Workspace.
144
144
  for (const continuation of blockingProviderContinuations(facts.events)) {
145
+ if (!providerContinuationBlocksCompletion(continuation, facts))
146
+ continue;
145
147
  const identity = continuation.identity;
146
148
  blockers.push({
147
149
  code: "blocking-provider-continuation",
@@ -209,6 +211,47 @@ export function projectCompletionReadiness(facts, options = {}) {
209
211
  });
210
212
  return { taskId: task.id, ready: sorted.length === 0, blockers: sorted };
211
213
  }
214
+ /**
215
+ * A terminal Run releases only its completion blocker, never its immutable
216
+ * continuation audit. Missing or inconsistent ownership remains ambiguous and
217
+ * therefore fail-closed. A live exact native Session can still deliver or
218
+ * write for a terminal Run, so it retains the blocker until that Session is
219
+ * stopped, broken, or replaced by another Conversation identity.
220
+ */
221
+ function providerContinuationBlocksCompletion(continuation, facts) {
222
+ if (continuation.identityConflict)
223
+ return true;
224
+ const ownerRun = facts.agentRuns.find(({ id }) => id === continuation.runId);
225
+ if (ownerRun === undefined
226
+ || ownerRun.taskId !== continuation.taskId
227
+ || ownerRun.roleName !== continuation.roleName
228
+ || ownerRun.effective.agentId !== continuation.identity.accountScope) {
229
+ return true;
230
+ }
231
+ if (ownerRun.status !== "failed" && ownerRun.status !== "yielded")
232
+ return true;
233
+ const sessions = facts.roleSessionSets.find(({ owner }) => (owner.taskId === continuation.taskId && owner.roleName === continuation.roleName));
234
+ const session = sessions?.sessions[continuation.identity.accountScope];
235
+ if (session !== undefined
236
+ && session.status !== "stopped"
237
+ && session.status !== "broken"
238
+ && session.nativeSessionId === continuation.identity.conversationId) {
239
+ return true;
240
+ }
241
+ const binding = sessions?.providerBinding;
242
+ if (binding === undefined || binding === null
243
+ || binding.providerNamespace !== continuation.identity.providerNamespace
244
+ || binding.accountScope !== continuation.identity.accountScope) {
245
+ return false;
246
+ }
247
+ const conversationIsCurrent = binding.conversations.some((conversation) => (conversation.conversationId === continuation.identity.conversationId
248
+ && conversation.epoch === binding.currentConversationEpoch
249
+ && conversation.status === "current"));
250
+ const activationIsLive = binding.activations.some((activation) => (activation.activationId === continuation.identity.activationId
251
+ && activation.conversationId === continuation.identity.conversationId
252
+ && activation.status === "active"));
253
+ return conversationIsCurrent && activationIsLive;
254
+ }
212
255
  function workspaceBlocker(facts, taskId, workspace) {
213
256
  const owner = workspace.owner;
214
257
  switch (owner.type) {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sameTaskFinalReviewContract, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
3
- import { currentWorkItemCandidate } from "../workItem/workItem.js";
3
+ import { currentWorkItemCandidate, governingWorkItemCandidate } from "../workItem/workItem.js";
4
4
  const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
5
5
  export function projectNextAction(facts) {
6
6
  const { task } = facts;
@@ -602,8 +602,7 @@ function selectOpenWorkItem(workItems) {
602
602
  }
603
603
  function taskFinalReviewContract(facts) {
604
604
  const contract = facts.workItems
605
- .flatMap((item) => item.candidates)
606
- .map((candidate) => candidate.taskFinalReviewContract)
605
+ .map((item) => governingWorkItemCandidate(item)?.taskFinalReviewContract)
607
606
  .find((contract) => contract !== undefined);
608
607
  return contract === undefined ? undefined : validateTaskFinalReviewContract(contract);
609
608
  }
@@ -680,7 +679,10 @@ function detectProtocolInconsistency(facts) {
680
679
  const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
681
680
  const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
682
681
  const contractedCandidates = facts.workItems
683
- .flatMap((item) => item.candidates.map((candidate) => ({ item, candidate })))
682
+ .flatMap((item) => {
683
+ const candidate = governingWorkItemCandidate(item);
684
+ return candidate === undefined ? [] : [{ item, candidate }];
685
+ })
684
686
  .filter(({ candidate }) => candidate.taskFinalReviewContract !== undefined);
685
687
  if (contractedCandidates.length > 1) {
686
688
  const first = validateTaskFinalReviewContract(contractedCandidates[0].candidate.taskFinalReviewContract);
@@ -1,6 +1,7 @@
1
1
  import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
2
2
  import { validateTaskRecordReference } from "./taskRecordReference.js";
3
3
  export const PUBLICATION_REFERENCE_SCHEMA_VERSION = 1;
4
+ export const TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT = "task.completion-published-tree-authorized";
4
5
  const PROVIDERS = new Set(["github", "gitlab"]);
5
6
  const EXTERNAL_KINDS = new Set(["pull-request", "merge-request"]);
6
7
  const STATES = new Set(["open", "merged", "closed"]);
@@ -1025,7 +1025,7 @@ export class TmuxManager {
1025
1025
  /**
1026
1026
  * Pin the Role window to the largest attached client. The tmux default
1027
1027
  * `window-size latest` lets a later-attaching smaller client (e.g. the Web
1028
- * terminal or a second `task enter` from a smaller pane) shrink the shared
1028
+ * terminal or a second Role viewer from a smaller pane) shrink the shared
1029
1029
  * Role window, leaving the primary viewer with a TUI pinned to the top of a
1030
1030
  * large terminal and no scrollback. `largest` keeps the window at the
1031
1031
  * biggest attached client so a compact viewer cannot compress it.
@@ -567,6 +567,18 @@ export function currentWorkItemCandidate(workItem) {
567
567
  ? workItem.candidates.at(-1)
568
568
  : undefined;
569
569
  }
570
+ /**
571
+ * Resolve the one Candidate that still governs Task delivery semantics.
572
+ * Awaiting Candidates remain under Leader disposition, while completed
573
+ * Candidates freeze accepted delivery evidence. Failed, retired, pending,
574
+ * and running WorkItems retain Candidate history only for audit; older
575
+ * Candidates on the same WorkItem are superseded by its latest Candidate.
576
+ */
577
+ export function governingWorkItemCandidate(workItem) {
578
+ return workItem.status === "awaiting_acceptance" || workItem.status === "completed"
579
+ ? workItem.candidates.at(-1)
580
+ : undefined;
581
+ }
570
582
  export function updateWorkItemWriteProjects(workItem, writeProjectIds, now) {
571
583
  validateWorkItem(workItem);
572
584
  if (isTerminalStatus(workItem.status)) {
@@ -4,6 +4,7 @@ import { createChangeSetManifest } from "../integration/changeSetManifest.js";
4
4
  import { deriveManifestTags } from "../integration/manifestTags.js";
5
5
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
6
6
  import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
7
+ import { governingWorkItemCandidate } from "../workItem/workItem.js";
7
8
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
8
9
  import { captureManagedGitChanges } from "./gitChangeSetCapture.js";
9
10
  const CAPTURABLE_WORK_ITEM_STATUSES = new Set([
@@ -427,7 +428,7 @@ function latestExactDirectAnchorId(store, taskId, contract) {
427
428
  return undefined;
428
429
  return store.listWorkItems(taskId)
429
430
  .filter((item) => {
430
- const candidate = item.candidates.at(-1);
431
+ const candidate = governingWorkItemCandidate(item);
431
432
  return candidate?.source.type === "direct"
432
433
  && sameTaskFinalReviewContract(candidate.taskFinalReviewContract, contract);
433
434
  })
@@ -435,20 +435,23 @@ Task 生命周期的交互选择只展示有效来源状态:activate 只展示
435
435
 
436
436
  ## Session 与 tmux
437
437
 
438
- tmux 负责 Agent 进程生命周期及其可观察输出。Global Operatorglobal Role 仍使用原生交互式 CLI;受管理的 Task Claude Run 则为每个 Run 启动一个有限生命周期进程,使用 `--print`、stream-json 输入和 stream-json 输出。Yui 通过 stdin 写入一条以换行结尾的精确 Run JSON user frame,并发排空 stdout/stderr,并通过 Claude session ID 保持原生上下文连续性。因此启动和投递不再依赖 TUI composer、ready 字符、粘贴延迟或模拟 Enter 键;Codex 保留其 adapter 原生的启动 prompt 与结构化 callback 路径。
438
+ 受管理的 Task Agent 统一使用混合 Provider Runtime:Controller Agent Host 通过 Provider 原生结构化协议提交和确认输入;tmux/PTY 只负责保持 Host 存活、展示输出,以及在显式人工接管后提供输入网关。受管理输入绝不会作为终端按键、粘贴文本或启动 argv 发送。Codex 使用持久 App Server JSON-RPC 进程;Claude 使用持久 stream-json 进程,并以精确回放的 user message 作为接收确认。
439
439
 
440
- `task enter` `task role enter` 只是附着到已存在的 Task Role pane:不会启动 Controller、准备 workspace、创建或恢复 Agent、唤醒 Role,也不会投递输入。Task attach 默认为 `--read-only`;只有显式指定 `--read-write` 才可交互,并且 Role 存在 active managed Run、受管理的 Claude 进程仍在退出,或同一 pane 已有 writer 时会被拒绝。读写 attach 会先发布 Role 级 tmux writer lease,再复核持久化 Run 状态,从而闭合与 Controller 启动之间的竞态。lease 存续期间只暂停该 Role 的受管理投递且不消耗有界投递重试;detach 会释放 lease,并且只通知已经存在的持久化 Role 工作重新评估,同一 Task 的其他 Role 不受影响。attach 前,Yui 会关闭 readline、退出 raw mode、暂停自身 stdin,再同步把终端交给 tmuxattach 会继承外层终端的真实能力并进入干净的 alternate screen;鼠标滚动只查看 Agent pane 的 100,000 行 tmux 历史,不再混入此前的 shell 或 IDE Terminal 历史。读写 attach 可以使用现有 pane 本身支持的原生交互,但它不参与受管理会话的启动或投递。
440
+ Run、Conversation、ActivationTurn 是四个独立身份。Conversation 可以跨多个 Run 和进程;Activation 只代表一次 Provider 进程存活期;Turn 在写入前先持久化。写入超时或结果不明确会进入 `delivery-unknown`,不会自动重发。Provider 进程退出后结束当前 Activation;恢复同一 Conversation 会创建新 Activation 并推进 authority epoch
441
441
 
442
- tmux 会在 pane 创建时固定其历史容量。配置该限制之前创建的 Role 会保留原容量;Yui 会在 Terminal attach 和 Web 中提示用户退出并重新进入一次,从而在保留 Agent 原生对话的同时创建具有 100,000 行历史的新 pane。
443
-
444
- Global 交互入口在不存在 writer 时保持可写;已有 writer 时自动降级为只读。global Web 对每个 tmux session 只允许一个 writer;Task Web 始终只读。Task CLI 入口除非显式请求 `--read-write`,否则始终只读,避免观察动作改变 Agent 执行。
442
+ Task Role 使用以下显式入口:
445
443
 
446
444
  ```sh
447
445
  yui session enter <global-role>
448
- yui task enter <task-id> [role] [--read-only | --read-write]
449
- yui task role enter <task-id> <role> [--read-only | --read-write]
446
+ yui task role view <task-id> <role>
447
+ yui task role takeover <task-id> <role>
448
+ yui task role release <task-id> <role>
450
449
  ```
451
450
 
451
+ `view` 始终只读。`takeover` 要求存在 active managed Run 且没有未决 Turn;它先以持久 CAS 把唯一 writer authority 转给人工 holder,再把相同 epoch 同步给 Agent Host,最后开放 PTY 输入网关。人工输入仍由 Host 转换为结构化 Provider Turn,而不是直接注入 Provider 终端。detach 会自动归还 authority;`release` 即使没有 active Run 也可执行,用于幂等修复中断或未完全同步的接管。Global Operator 与 global Role 继续使用原生交互式 CLI,不属于受管理 Task Provider 协议。
452
+
453
+ tmux 会在 pane 创建时固定其历史容量。配置该限制之前创建的 Role 会保留原容量;Yui 会在 Terminal attach 和 Web 中提示用户退出并重新进入一次,从而创建具有 100,000 行历史的新 pane。
454
+
452
455
  每个 Role 可绑定多个 Agent,但任一时刻只有一个 active Agent,并为每个
453
456
  Agent binding 独立保存 native session。Operator 进一步限制为同一种
454
457
  adapter 最多绑定一个,例如可同时绑定一个 Codex 和一个 Claude;这些
@@ -460,7 +463,7 @@ binding 是预先保存、可随时切换的配置,而不是并行身份。Ope
460
463
 
461
464
  使用 `yui config role unbind <global-role> <agent-id>` 或 `yui task role unbind <task-id> <role> <agent-id>` 可移除休眠 binding。active binding 或任何未 stopped 的 native session 都会被拒绝;stopped session 记录会和 binding 在同一事务中删除。
462
465
 
463
- Claude 的 session ID 在启动前分配。每个受管理的 Task Claude Run 都使用新的有限生命周期进程;resume 会针对固定 native session 启动新进程,而不是复用交互式 pane。受管理的 Codex 启动使用 Codex 结构化 `notify` 回调,在 turn 完成后记录 thread ID,不再向模型对话注入 session-bind prompt。
466
+ Claude 的 session ID 在启动前分配,并由持久 stream-json Provider 进程承载多个 Turn;Codex 使用持久 App Server thread。两者都复用同一套 Conversation、Activation、Turn authority fence,不再向模型对话注入 session-bind prompt。
464
467
 
465
468
  自动生命周期与投递判断只使用结构化 Hook payload、持久身份、tmux process
466
469
  state、receipt 与 pane fence。Yui 不会解析 prompt glyph、进度文本、trust dialog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -521,9 +521,14 @@ authorized expansions.
521
521
  completion; routine retries and routing do not need an InputRequest.
522
522
  - A failed review is terminal evidence, not an automatic retry. Retry a
523
523
  WorkItem review with a new `task work review`, accept with an explicit
524
- rationale, or ask the user. For an exact failed Task-scoped final Review Run,
525
- `yui task run retry <run-id>` requests one independent ReviewRound over the
526
- same frozen Task candidate; repeating the same exact retry reuses that Round.
524
+ rationale, or ask the user. `yui task run retry <run-id>` retries an exact
525
+ failed Task-final Reviewer execution under the same semantic ReviewRound. If
526
+ that immutable Round itself failed without any semantic report, checks,
527
+ yield, evidence, or finding, the Leader may run
528
+ `yui task review force-fresh <task>/<review-round>` to create one distinct
529
+ full Round over the identical frozen heads. It fails closed for every
530
+ semantic or ambiguous prior result; target the new failed Round explicitly
531
+ if another non-semantic failure occurs.
527
532
  - If the same non-resource user choice or unavailable external fact repeats,
528
533
  persist context and create an InputRequest instead of looping. Never use an
529
534
  InputRequest to solicit authorization for an unrequested real-resource test;
@@ -23,12 +23,17 @@ For every managed Task Run:
23
23
  context-load failure if the pack is missing, stale, unauthorized, malformed,
24
24
  or mismatched. Never request an inline/full-prompt fallback.
25
25
  4. Use pack summaries and pointers first. Expand only an authorized ref when
26
- its full value is needed:
26
+ its full value is needed, selecting it by the pointer's exact `store` and
27
+ `refId`:
27
28
 
28
29
  ```sh
29
- "$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --mode full --json
30
+ "$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --store <store> --mode full --json
30
31
  ```
31
32
 
33
+ A bare `<ref-id>` remains supported only when it identifies exactly one
34
+ authorized pointer. If multiple stores use that id, bare expansion fails
35
+ closed; never guess which store was intended.
36
+
32
37
  5. On a later wake, request only the declared delta after the last pack cursor.
33
38
  If no cursor is available, reload the exact pack; do not reconstruct state
34
39
  from transcript memory.