@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -1,6 +1,6 @@
1
1
  import { roleLaunchEventPayload, saveTaskRoleUpdate } from "../role/taskRoleUpdate.js";
2
- import { randomUUID } from "node:crypto";
3
- import { join } from "node:path";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { archiveDeliveryWarnings, archiveRetainedResources, renderArchiveDiagnostics, taskArchiveDiagnostics } from "../task/archiveDiagnostics.js";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { createRunInput } from "../context/runInputContract.js";
6
6
  import { buildRunContextPack, buildRunContextDelta, contextSnapshotDeltaRefIds, expandRunContextRef, freezeWorkItemExecutionAssignmentContextSnapshot, freezeReviewStageContextSnapshot, freezeRunContextSnapshot } from "../context/runContextPack.js";
@@ -20,7 +20,7 @@ import { agentExecutionComponentLabel } from "../agent/executionComponents.js";
20
20
  import { agentRunConfigurationLabel, renderAgentRunConfiguration } from "../output/agentRunConfigurationPresentation.js";
21
21
  import { formatTimestamp } from "../output/timePresentation.js";
22
22
  import { renderRoleDetails, renderRoleLaunchComparison } from "../output/rolePresentation.js";
23
- import { createTaskMessage, expandTaskMessageResult, taskMessageAuthorLabel, updateDraftTaskMessage, withSubmissionReceipt, TASK_SUBMISSION_INTENTS } from "../message/message.js";
23
+ import { createTaskMessage, expandTaskMessageResult, recordTaskMessageControlOutcome, taskMessageAuthorLabel, taskMessageInputControlState, updateDraftTaskMessage, withSubmissionReceipt, TASK_SUBMISSION_INTENTS } from "../message/message.js";
24
24
  import { assertDraftTaskExecutionFree, validateDraftWorkItemEdit } from "../task/draftPlan.js";
25
25
  import { TASK_PLANNING_ENTERED_EVENT, decideSubmissionRouting, describeSubmissionFeedback, draftActivationState, draftHasEnteredPlanning, normalizeSubmissionIntent, sameSubmissionTarget } from "../task/taskSubmission.js";
26
26
  import { recordTaskActivationRequestInTransaction, taskActivationOperationRef } from "../task/taskActivationService.js";
@@ -47,6 +47,7 @@ import { addTaskProjectBinding, archiveTask, completeTask, createTask, retireTas
47
47
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
48
48
  import { projectCompletionReadiness } from "../task/completionReadiness.js";
49
49
  import { StorageConflictError } from "../storage/taskStore.js";
50
+ import { planningRuntimeCwd } from "../storage/homeLayout.js";
50
51
  import { requireResolvedAgentProfileRuntime } from "../profile/agentProfileRuntime.js";
51
52
  import { assertProjectActive, resolveProject } from "../repository/project.js";
52
53
  import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, editWorkItemDefinition, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, prepareWorkItemDispatch, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
@@ -68,13 +69,15 @@ import { createProjectResources } from "../resources/projectResourceService.js";
68
69
  import { isGitArtifactRefString, parseGitArtifactRef } from "../artifacts/gitArtifactRef.js";
69
70
  import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
70
71
  import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
71
- import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleLastRunLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel } from "./taskRoleRuntimeStatus.js";
72
+ import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleLastRunLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel, withTaskRoleHostObservation, taskRoleHostDiagnostic } from "./taskRoleRuntimeStatus.js";
72
73
  import { assertNoOpenInputRequests, openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
73
74
  import { runGrantCommand } from "./grantCommands.js";
74
75
  import { runWorkflowCommand } from "./workflowCommands.js";
75
- import { taskLocalActor as resolveTaskLocalActor, assertTaskDeliveryAuthority } from "./taskActor.js";
76
+ import { taskLocalActor as resolveTaskLocalActor, assertTaskDeliveryAuthority, assertTaskInputControlAuthority } from "./taskActor.js";
76
77
  import { currentManagedRuntime, resolveManagedTaskReader } from "../runtime/managedCaller.js";
77
78
  import { resolveMessageRecipient, messageContinuationBlocker } from "../message/messageContinuation.js";
79
+ import { findTaskInterrupt, reserveTaskInterrupt, taskInterruptReceipt, taskInterruptWasRejected } from "../message/taskInterrupt.js";
80
+ import { resolveTaskInputControl } from "../message/inputControlResolution.js";
78
81
  import { enqueueOperatorEvent } from "../scheduler/operatorEvent.js";
79
82
  import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
80
83
  import { renderWakeReason, wakeReason } from "../scheduler/wakeReason.js";
@@ -317,7 +320,7 @@ export function runTaskCommand(args, store, options = {}) {
317
320
  case "activation": return runTaskActivationCommand(rest, store, options);
318
321
  case "complete": return completeTaskCommand(rest, store, options);
319
322
  case "reopen": return output(reopenTaskCommand(rest, store, options));
320
- case "archive": return output(archiveTaskCommand(rest, store, options));
323
+ case "archive": return archiveTaskCommand(rest, store, options);
321
324
  case "retire": return retireTaskCommand(rest, store, options);
322
325
  case "cancel": return cancelTaskCommand(rest, store, options);
323
326
  case "reconcile": return output(reconcileTaskCommand(rest, store, options));
@@ -545,7 +548,7 @@ export function updateTaskMetadataCommand(store, taskId, patch, options = {}) {
545
548
  notifyMailbox(options.runtime, taskMailbox(result.id), result.id);
546
549
  return result;
547
550
  }
548
- function routeUserSubmission(tx, task, actor, body, intent, now, submissionKey, target) {
551
+ function routeUserSubmission(tx, task, actor, body, intent, now, submissionKey, target, inputControl) {
549
552
  const kind = actor;
550
553
  const author = actor === "operator"
551
554
  ? { type: "operator" }
@@ -564,7 +567,8 @@ function routeUserSubmission(tx, task, actor, body, intent, now, submissionKey,
564
567
  // how routing then resolves (§2.5). Intent is stored, never re-read from the body.
565
568
  const message = appendMessage(tx, task.id, body, kind, author, now, {
566
569
  intent,
567
- ...(submissionKey === undefined ? {} : { submissionKey })
570
+ ...(submissionKey === undefined ? {} : { submissionKey }),
571
+ ...(inputControl === undefined ? {} : { inputControl })
568
572
  });
569
573
  // Re-read phase and activation inside this same transaction: this is the point
570
574
  // the race is decided at (§2.3).
@@ -924,6 +928,7 @@ function showTaskCommand(args, store, currentTaskCandidate) {
924
928
  const integrations = store.listIntegrationAttempts(task.id);
925
929
  const publications = store.listPublicationReferences(task.id);
926
930
  const remoteDelivery = projectTaskRemoteDeliveryFromStore(store, task, currentTaskCandidate);
931
+ const archive = task.status === "archived" ? taskArchiveDiagnostics(store, task) : undefined;
927
932
  const verifiedMergedPublications = publications.filter((reference) => (reference.state === "merged" && reference.verification === "verified")).length;
928
933
  const currentMessageCount = operationalTaskRecords(messages, events, "message").length;
929
934
  const currentWorkItemCount = work.filter(({ status }) => status !== "retired").length;
@@ -981,6 +986,7 @@ function showTaskCommand(args, store, currentTaskCandidate) {
981
986
  `Integration Attempts: ${counts.integrations}`,
982
987
  `Publication references: ${counts.publications} (${verifiedMergedPublications} verified merged)`,
983
988
  renderTaskRemoteDelivery(remoteDelivery).trimEnd(),
989
+ ...(archive === undefined ? [] : [renderArchiveDiagnostics(archive).trimEnd()]),
984
990
  `Open inputs: ${counts.openInputs}`,
985
991
  `Created: ${presentTime(task.createdAt, timeZone)}`,
986
992
  `Updated: ${presentTime(task.updatedAt, timeZone)}`
@@ -989,7 +995,8 @@ function showTaskCommand(args, store, currentTaskCandidate) {
989
995
  task,
990
996
  counts,
991
997
  hasBrief: brief !== null,
992
- remoteDelivery
998
+ remoteDelivery,
999
+ ...(archive === undefined ? {} : { archive })
993
1000
  });
994
1001
  }
995
1002
  function activateTaskCommand(args, store, options) {
@@ -1254,50 +1261,59 @@ function archiveTaskCommand(args, store, options) {
1254
1261
  && task.status !== "cancelled") {
1255
1262
  throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
1256
1263
  }
1257
- const remoteDelivery = request.disposition === "integrated"
1258
- ? assertTaskRemoteDeliveryProof(tx, task, options.archiveRemoteDeliveryProof, { forceUnverified: request.forceUnverified })
1259
- : undefined;
1260
- assertNoOpenInputRequests(tx, task.id, "archiving the Task");
1261
- const unsettledWork = tx.listWorkItems(task.id).find((item) => item.status === "open");
1262
- if (unsettledWork !== undefined) {
1263
- throw usageError(`Work Item ${unsettledWork.id} must be accepted or explicitly retired before archive.`);
1264
- }
1265
- const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
1266
- || integration.status === "blocked"
1267
- || integration.status === "validating"));
1268
- if (unresolvedIntegration !== undefined) {
1269
- throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
1270
- }
1271
- const activeArchiveJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
1272
- || job.status === "running"
1273
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
1274
- if (activeArchiveJob !== undefined) {
1275
- throw usageError(`Task ${task.id} has an active DurableJob: ${activeArchiveJob.id}/${activeArchiveJob.status}.`);
1276
- }
1277
- if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
1278
- throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
1279
- }
1280
- const activeRole = tx.listRoles(task.id)
1281
- .find((role) => tx.getActiveRun(task.id, role.name) !== null);
1282
- if (activeRole !== undefined) {
1283
- throw usageError(`Task ${task.id} still has an active AgentRun for Role ${activeRole.name}; `
1284
- + "stop its runtime before archiving.");
1285
- }
1286
- const liveSessionRole = tx.listRoles(task.id).find((role) => {
1287
- const sessions = tx.getTaskRoleSessionSet(task.id, role.name);
1288
- const session = sessions?.sessions[sessions.activeAgentId];
1289
- return session !== undefined && session.status !== "ended";
1290
- });
1291
- if (liveSessionRole !== undefined) {
1292
- throw usageError(`Task ${task.id} still has a live Session for Role ${liveSessionRole.name}; `
1293
- + "stop that Session before archiving.");
1264
+ const remoteDelivery = request.force
1265
+ ? projectTaskRemoteDeliveryFromStore(tx, task)
1266
+ : request.disposition === "integrated"
1267
+ ? assertTaskRemoteDeliveryProof(tx, task, options.archiveRemoteDeliveryProof)
1268
+ : undefined;
1269
+ if (!request.force) {
1270
+ assertNoOpenInputRequests(tx, task.id, "archiving the Task");
1271
+ const unsettledWork = tx.listWorkItems(task.id).find((item) => item.status === "open");
1272
+ if (unsettledWork !== undefined) {
1273
+ throw usageError(`Work Item ${unsettledWork.id} must be accepted or explicitly retired before archive.`);
1274
+ }
1275
+ const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
1276
+ || integration.status === "blocked"
1277
+ || integration.status === "conflicted"
1278
+ || integration.status === "validating"));
1279
+ if (unresolvedIntegration !== undefined) {
1280
+ throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
1281
+ }
1282
+ const activeArchiveJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
1283
+ || job.status === "running"
1284
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
1285
+ if (activeArchiveJob !== undefined) {
1286
+ throw usageError(`Task ${task.id} has an active DurableJob: ${activeArchiveJob.id}/${activeArchiveJob.status}.`);
1287
+ }
1288
+ if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
1289
+ throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
1290
+ }
1291
+ const activeRole = tx.listRoles(task.id)
1292
+ .find((role) => tx.getActiveRun(task.id, role.name) !== null);
1293
+ if (activeRole !== undefined) {
1294
+ throw usageError(`Task ${task.id} still has an active AgentRun for Role ${activeRole.name}; `
1295
+ + "stop its runtime before archiving.");
1296
+ }
1297
+ const liveSessionRole = tx.listRoles(task.id).find((role) => {
1298
+ const sessions = tx.getTaskRoleSessionSet(task.id, role.name);
1299
+ const session = sessions?.sessions[sessions.activeAgentId];
1300
+ return session !== undefined && session.status !== "ended";
1301
+ });
1302
+ if (liveSessionRole !== undefined) {
1303
+ throw usageError(`Task ${task.id} still has a live Session for Role ${liveSessionRole.name}; `
1304
+ + "stop that Session before archiving.");
1305
+ }
1294
1306
  }
1295
- const archived = archiveTask(task, now, { by: actor });
1307
+ const retainedResources = request.force ? archiveRetainedResources(tx, task) : [];
1308
+ const warnings = request.force && remoteDelivery !== undefined ? archiveDeliveryWarnings(remoteDelivery) : [];
1309
+ const archived = { ...archiveTask(task, now, { by: actor }), executionGate: { state: "stopped" } };
1296
1310
  tx.saveTask(archived);
1297
- tx.clearPendingWakeup(task.id);
1298
1311
  tx.clearLeaderFailure(task.id);
1299
- for (const role of tx.listRoles(task.id)) {
1300
- tx.removeWorkMailbox(roleMailbox(task.id, role.name));
1312
+ if (!request.force) {
1313
+ tx.clearPendingWakeup(task.id);
1314
+ for (const role of tx.listRoles(task.id)) {
1315
+ tx.removeWorkMailbox(roleMailbox(task.id, role.name));
1316
+ }
1301
1317
  }
1302
1318
  const remoteProjectHeads = remoteDelivery === undefined
1303
1319
  ? undefined
@@ -1314,27 +1330,29 @@ function archiveTaskCommand(args, store, options) {
1314
1330
  recordTaskEvent(tx, task.id, "task.archived", {
1315
1331
  by: actor,
1316
1332
  workspaceDisposition: request.disposition,
1333
+ ...(request.force ? {
1334
+ force: "true", cleanup: "pending",
1335
+ warnings: JSON.stringify(warnings),
1336
+ retainedResources: JSON.stringify(retainedResources)
1337
+ } : {}),
1317
1338
  ...(remoteDelivery === undefined
1318
1339
  ? {}
1319
1340
  : {
1320
1341
  mergeCoverage: remoteDelivery.status,
1321
1342
  allMerged: String(remoteDelivery.allMerged),
1322
- allVerified: String(remoteDelivery.allVerified),
1323
- ...(request.forceUnverified && !remoteDelivery.allVerified
1324
- ? { verificationOverride: "true" }
1325
- : {})
1343
+ allVerified: String(remoteDelivery.allVerified)
1326
1344
  }),
1327
1345
  ...(remoteProjectHeads === undefined ? {} : { projectHeads: remoteProjectHeads }),
1328
1346
  ...(remoteProjectBases === undefined ? {} : { projectBases: remoteProjectBases })
1329
1347
  }, now);
1330
- enqueueWork(tx, taskMailbox(task.id), "task-archived", now, [taskRef(task.id)]);
1331
1348
  return { task: archived, changed: true };
1332
1349
  });
1333
- if (result.changed)
1334
- notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
1335
- return result.changed
1350
+ if (result.changed && !request.force)
1351
+ options.runtime?.notifyStateChanged(result.task.id);
1352
+ const diagnostics = taskArchiveDiagnostics(store, result.task);
1353
+ return output((result.changed
1336
1354
  ? `Archived task ${result.task.id}\n`
1337
- : `Task ${result.task.id} is already archived\n`;
1355
+ : `Task ${result.task.id} is already archived\n`) + renderArchiveDiagnostics(diagnostics), { task: result.task, changed: result.changed, ...diagnostics });
1338
1356
  }
1339
1357
  function cancelTaskCommand(args, store, options) {
1340
1358
  const usage = "Task cancel usage: yui task cancel <task> (--summary <text>|--summary-file <path|->).";
@@ -1400,6 +1418,7 @@ function retireTaskCommand(args, store, options) {
1400
1418
  }
1401
1419
  const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
1402
1420
  || integration.status === "blocked"
1421
+ || integration.status === "conflicted"
1403
1422
  || integration.status === "validating"));
1404
1423
  if (unresolvedIntegration !== undefined) {
1405
1424
  throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
@@ -1495,7 +1514,7 @@ function assertTaskRetirementProof(store, task, proof) {
1495
1514
  }
1496
1515
  export function parseTaskArchiveArguments(args) {
1497
1516
  const usage = "Task archive usage: "
1498
- + "yui task archive <id> (--integrated [--force]|--abandon).";
1517
+ + "yui task archive <id> (--integrated|--abandon) [--force].";
1499
1518
  const taskId = args[0]?.trim();
1500
1519
  const flags = args.slice(1);
1501
1520
  if (taskId === undefined
@@ -1506,14 +1525,14 @@ export function parseTaskArchiveArguments(args) {
1506
1525
  }
1507
1526
  const integrated = flags.includes("--integrated");
1508
1527
  const abandoned = flags.includes("--abandon");
1509
- const forceUnverified = flags.includes("--force");
1510
- if (integrated === abandoned || (forceUnverified && !integrated)) {
1528
+ const force = flags.includes("--force");
1529
+ if (integrated === abandoned) {
1511
1530
  throw usageError(usage);
1512
1531
  }
1513
1532
  return {
1514
1533
  taskId,
1515
1534
  disposition: integrated ? "integrated" : "abandoned",
1516
- forceUnverified
1535
+ force
1517
1536
  };
1518
1537
  }
1519
1538
  function formatProjectCommits(projects) {
@@ -1532,10 +1551,11 @@ export function validateTaskArchiveRequest(args, store, options = {}) {
1532
1551
  && task.status !== "cancelled") {
1533
1552
  throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
1534
1553
  }
1535
- if (task.status !== "archived") {
1554
+ if (task.status !== "archived" && !request.force) {
1536
1555
  assertNoOpenInputRequests(store, task.id, "archiving the Task");
1537
1556
  const unresolvedIntegration = store.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
1538
1557
  || integration.status === "blocked"
1558
+ || integration.status === "conflicted"
1539
1559
  || integration.status === "validating"));
1540
1560
  if (unresolvedIntegration !== undefined) {
1541
1561
  throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
@@ -1637,6 +1657,10 @@ function taskMessageCommand(args, store, options) {
1637
1657
  ? { state: "queued" } : { state: "saved" };
1638
1658
  return output(`Saved message ${result.message.id} to ${result.task.id} (${delivery.state}${reason === undefined ? "" : `: ${reason}`}).\n`, { taskId: result.task.id, message: result.message, delivery });
1639
1659
  }
1660
+ if (command === "queue")
1661
+ return queueTaskMessage(rest, store, options);
1662
+ if (command === "steer")
1663
+ return steerTaskMessage(rest, store, options);
1640
1664
  if (command === "list") {
1641
1665
  const messageListUsage = "Task message list usage: yui task message list <id> [--after <timestamp>] [--limit <n>].";
1642
1666
  const parsed = parseTail(rest, new Set(["--after", "--limit"]), messageListUsage);
@@ -1690,6 +1714,149 @@ function taskMessageCommand(args, store, options) {
1690
1714
  ? "Task message command is required."
1691
1715
  : `Unknown command: task message ${command}`);
1692
1716
  }
1717
+ /** CLI and authenticated user Surface share the same message and mailbox
1718
+ * transaction. Talking to Leader does not impersonate Leader authority. */
1719
+ /**
1720
+ * `queue` is the explicit, idempotent form of the existing send: it persists a
1721
+ * Message tagged with the durable `queue` action and a stable requestId, and
1722
+ * relies on the identical continuation path as `send`. Repeating the exact same
1723
+ * (requestId, body) returns the original Message and delivers nothing twice;
1724
+ * reusing the requestId with different content is a conflict, never a second
1725
+ * input (decision-3 §5/§6).
1726
+ */
1727
+ function queueTaskMessage(args, store, options) {
1728
+ const usage = "Task message queue usage: yui task message queue <id> (<body>|--body-file <path|->) --request-id <id> [--to <role> --work-item <id>|--review-round <id>].";
1729
+ const parsed = parseTail(args, new Set(["--body-file", "--request-id", "--to", "--work-item", "--review-round"]), usage);
1730
+ if (parsed.positionals.length < 1 || parsed.positionals.length > 2)
1731
+ throw usageError(usage);
1732
+ const body = readCommandText(parsed.positionals[1], parsed.options.get("--body-file"), "--body", usage);
1733
+ const requestId = requiredOption(parsed.options, "--request-id");
1734
+ const recipientRole = parsed.options.get("--to");
1735
+ const workItemId = parsed.options.get("--work-item");
1736
+ const reviewRoundId = parsed.options.get("--review-round");
1737
+ if (recipientRole === undefined && (workItemId !== undefined || reviewRoundId !== undefined)) {
1738
+ throw usageError("--to is required for scoped Message delivery.");
1739
+ }
1740
+ const result = sendTaskMessageCommand(store, parsed.positionals[0], body, undefined, options, recipientRole === undefined ? undefined : { roleName: recipientRole, workItemId, reviewRoundId }, undefined, undefined, { action: "queue", requestId });
1741
+ const reason = result.message.continuation?.notDeliveredReason;
1742
+ const delivery = result.idempotentReplay ? { state: "idempotent-replay" }
1743
+ : reason !== undefined ? { state: "not-delivered", reason }
1744
+ : recipientRole !== undefined || result.actor !== "leader" ? { state: "queued" } : { state: "saved" };
1745
+ return output(`Queued message ${result.message.id} to ${result.task.id} (${delivery.state}${reason === undefined ? "" : `: ${reason}`}).\n`, { taskId: result.task.id, message: result.message, delivery });
1746
+ }
1747
+ /**
1748
+ * `steer` targets only the exact current native Turn. It always persists the
1749
+ * Message (so an unsupported or missed steer stays visible and re-choosable),
1750
+ * then resolves the live target from durable state with no Provider call. On a
1751
+ * ready resolution it returns a structured intent the CLI performs against the
1752
+ * Agent Host; on any explicit failure it returns the saved Message plus the
1753
+ * exact error code, and never falls back to an interrupt or a queue
1754
+ * (decision-3 §1/§5). The steer Message is excluded from the queued
1755
+ * continuation path, so a failed live attempt cannot silently become a queue.
1756
+ */
1757
+ function steerTaskMessage(args, store, options) {
1758
+ const usage = "Task message steer usage: yui task message steer <id> (<body>|--body-file <path|->) --request-id <id> --expected-target <turn> [--to <role> --work-item <id>|--review-round <id>].";
1759
+ const parsed = parseTail(args, new Set(["--body-file", "--request-id", "--expected-target", "--to", "--work-item", "--review-round"]), usage);
1760
+ if (parsed.positionals.length < 1 || parsed.positionals.length > 2)
1761
+ throw usageError(usage);
1762
+ const body = readCommandText(parsed.positionals[1], parsed.options.get("--body-file"), "--body", usage);
1763
+ const requestId = requiredOption(parsed.options, "--request-id");
1764
+ const expectedTarget = requiredOption(parsed.options, "--expected-target");
1765
+ const recipientRole = parsed.options.get("--to");
1766
+ const workItemId = parsed.options.get("--work-item");
1767
+ const reviewRoundId = parsed.options.get("--review-round");
1768
+ if (recipientRole === undefined) {
1769
+ throw usageError("steer requires --to <role>; it targets that Role's exact current Turn.");
1770
+ }
1771
+ // decision-3 §9: a Task Leader's current native turn (including a Draft's
1772
+ // planning turn) is a real turn but NOT an implicit AgentRun, so steering it
1773
+ // never establishes a Worker-style Assignment. It is persisted as an ordinary
1774
+ // no-Run Leader notification (recipient undefined, wake "none" so it never
1775
+ // spawns a Leader AgentRun) and its live target is the Leader's own turn. A
1776
+ // Worker/Reviewer steer keeps the exact Assignment recipient it always had.
1777
+ const leaderTarget = recipientRole === "leader";
1778
+ if (leaderTarget && (workItemId !== undefined || reviewRoundId !== undefined)) {
1779
+ throw usageError("Steering the Leader targets its current turn directly; it takes no --work-item/--review-round Assignment.");
1780
+ }
1781
+ const result = leaderTarget
1782
+ ? sendTaskMessageCommand(store, parsed.positionals[0], body, "none", options, undefined, undefined, undefined, { action: "steer", requestId, expectedTarget })
1783
+ : sendTaskMessageCommand(store, parsed.positionals[0], body, undefined, options, { roleName: recipientRole, workItemId, reviewRoundId }, undefined, undefined, { action: "steer", requestId, expectedTarget });
1784
+ const roleName = result.message.recipient?.roleName ?? recipientRole;
1785
+ if (result.idempotentReplay) {
1786
+ // A replay is a later reader (another CLI invocation): surface the durable
1787
+ // control state the Host settlement folded onto this exact Message, so an
1788
+ // unknown steer is visibly not-submitted/pending/accepted/rejected/
1789
+ // delivery-unknown rather than silently re-attempted (message-5 gap D).
1790
+ const controlState = taskMessageInputControlState(result.message);
1791
+ return output(`Steer message ${result.message.id} already recorded (idempotent-replay${controlState === undefined ? "" : `; control ${controlState}`}).\n`, { taskId: result.task.id, message: result.message,
1792
+ steer: { state: "idempotent-replay",
1793
+ ...(controlState === undefined ? {} : { control: controlState }) } });
1794
+ }
1795
+ // The static resolution is the testable contract: capability gate, exact-turn
1796
+ // match, and writer fence, all from durable state without a live Endpoint.
1797
+ const resolution = resolveTaskInputControl(store, result.task.id, roleName, "steer", expectedTarget);
1798
+ if (resolution.outcome !== "ready") {
1799
+ return output(`Steer message ${result.message.id} saved but not delivered (${resolution.code}: ${resolution.detail}).\n`, { taskId: result.task.id, message: result.message,
1800
+ steer: { state: "not-steered", code: resolution.code, detail: resolution.detail } });
1801
+ }
1802
+ if (!leaderTarget) {
1803
+ const current = store.getActiveRun(result.task.id, roleName);
1804
+ const native = store.getTaskRoleSessionSet(result.task.id, roleName)?.providerBinding?.run;
1805
+ const recipient = result.message.recipient;
1806
+ if (current === null || native?.runId !== current.id
1807
+ || recipient?.ownerRunId !== current.id
1808
+ || recipient.workItemId !== current.workItemId
1809
+ || recipient.reviewRoundId !== current.reviewRoundId
1810
+ || messageContinuationBlocker(store, result.message) !== undefined) {
1811
+ return output(`Steer message ${result.message.id} saved but not delivered (TARGET_CHANGED: active Assignment differs).\n`, { taskId: result.task.id, message: result.message,
1812
+ steer: { state: "not-steered", code: "TARGET_CHANGED", detail: "The input does not belong to the active Turn's Assignment." } });
1813
+ }
1814
+ }
1815
+ const receiptId = `steer:${result.task.id}/${result.message.id}`;
1816
+ // Register the one independent control attempt as `pending` before the live
1817
+ // edge runs (decision-3 §3, message-5 gap D). This is the messageRef-associated
1818
+ // control op that makes a dispatched-but-unproven steer visibly distinct from a
1819
+ // steer that was merely saved and never attempted (`not-submitted`). It is
1820
+ // keyed by this exact receiptId and the steer's own requestId, and is monotonic
1821
+ // and idempotent, so the Host's later settlement fold promotes it to a proven
1822
+ // terminal and never rewinds it. An idempotent replay above already returned
1823
+ // the saved Message, so this records the attempt exactly once.
1824
+ store.transaction((tx) => {
1825
+ const saved = tx.listMessages(result.task.id).find((entry) => entry.id === result.message.id);
1826
+ const requestId = saved?.inputControl?.requestId;
1827
+ if (saved === undefined || requestId === undefined)
1828
+ return;
1829
+ tx.updateMessage(result.task.id, recordTaskMessageControlOutcome(saved, {
1830
+ requestId, receiptId, outcome: "pending", observedAt: clock(options)
1831
+ }));
1832
+ });
1833
+ return {
1834
+ kind: "input-steer",
1835
+ taskId: result.task.id,
1836
+ roleName,
1837
+ messageId: result.message.id,
1838
+ target: resolution.target,
1839
+ receiptId,
1840
+ // decision-3 §9: deliver the input as an authorized, reconcilable delta, not
1841
+ // loose body text and not a bare Message id. The Host additionally decorates
1842
+ // this with the Session Manifest read pointer, so the running turn can read
1843
+ // the exact durable Message through its authorized Context path.
1844
+ text: steerInputDelivery(result.task.id, result.message.id, roleName, body),
1845
+ output: `Steering ${result.task.id}/${roleName} at Turn ${resolution.target.nativeTurnId ?? resolution.target.attemptId} with message ${result.message.id}.\n`
1846
+ };
1847
+ }
1848
+ /** Compose the exact input a steer pushes into the live turn: a bounded header
1849
+ * that names the durable Message and states receipt-is-not-acceptance, then the
1850
+ * verbatim body. The header keeps the input reconcilable with durable state
1851
+ * (decision-3 §9) without copying the body twice or fabricating a Run. */
1852
+ function steerInputDelivery(taskId, messageId, roleName, body) {
1853
+ return [
1854
+ `[Steer ${taskId}/${messageId} → ${roleName}] Additional input to your current turn; receipt is not acceptance of prior work.`,
1855
+ `It is saved durably as Message ${messageId}; reconcile it through your Session Manifest's authorized Context read path.`,
1856
+ "",
1857
+ body
1858
+ ].join("\n");
1859
+ }
1693
1860
  /** CLI and authenticated user Surface share the same message and mailbox
1694
1861
  * transaction. Talking to Leader does not impersonate Leader authority. */
1695
1862
  /**
@@ -1708,7 +1875,7 @@ function taskMessageCommand(args, store, options) {
1708
1875
  * execution is decided by the Controller from the Task's own status, so this
1709
1876
  * function never names a purpose and no second planning path exists.
1710
1877
  */
1711
- export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options = {}, recipient, intent, submissionKey) {
1878
+ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options = {}, recipient, intent, submissionKey, inputControl) {
1712
1879
  if (!body.trim())
1713
1880
  throw usageError("Message body is required.");
1714
1881
  if (recipient !== undefined && wakePolicy !== undefined) {
@@ -1723,6 +1890,32 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
1723
1890
  const now = clock(options);
1724
1891
  const result = store.transaction((tx) => {
1725
1892
  const task = requireTask(tx, taskId);
1893
+ // A stable requestId makes the whole send idempotent: an exact repeat
1894
+ // returns the original Message, and any different body/action/recipient/
1895
+ // target under the same id is a conflicting reuse, never a silent second
1896
+ // input (decision-3 §6). The prior input is matched by requestId across
1897
+ // both a live inputControl and the reusedInput provenance an interrupt-then
1898
+ // handoff preserved, so re-tagging a steer as a handoff never frees its
1899
+ // requestId to create a second Message.
1900
+ if (inputControl !== undefined) {
1901
+ const existing = tx.listMessages(task.id).find((entry) => entry.inputControl?.requestId === inputControl.requestId
1902
+ || entry.interruptThen?.reusedInput?.requestId === inputControl.requestId);
1903
+ if (existing !== undefined) {
1904
+ const prior = existing.inputControl ?? existing.interruptThen?.reusedInput;
1905
+ const priorRecipient = existing.recipient === undefined ? undefined
1906
+ : { roleName: existing.recipient.roleName, workItemId: existing.recipient.workItemId,
1907
+ reviewRoundId: existing.recipient.reviewRoundId };
1908
+ const nextRecipient = recipient === undefined ? undefined
1909
+ : { roleName: recipient.roleName, workItemId: recipient.workItemId, reviewRoundId: recipient.reviewRoundId };
1910
+ if (prior?.action !== inputControl.action || existing.body !== body
1911
+ || prior.expectedTarget !== inputControl.expectedTarget
1912
+ || !isDeepStrictEqual(priorRecipient, nextRecipient)) {
1913
+ throw usageError(`Input requestId ${inputControl.requestId} was already used with different content or target; use a new requestId for a new input.`);
1914
+ }
1915
+ return { task, message: existing, actor: replayActor(existing), queuedForLeader: false,
1916
+ feedback: existing.submissionReceipt?.feedback, idempotentReplay: true };
1917
+ }
1918
+ }
1726
1919
  if (recipient === undefined)
1727
1920
  assertTaskOpen(task);
1728
1921
  const caller = currentManagedRuntime(tx, options.environment, task.id);
@@ -1743,10 +1936,10 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
1743
1936
  // gain develop authority (task-32 §2.5).
1744
1937
  if (recipient === undefined && (actor === "user" || actor === "operator")) {
1745
1938
  const effectiveIntent = normalizeSubmissionIntent(intent, wakePolicy);
1746
- const routed = routeUserSubmission(tx, task, actor, body, effectiveIntent, now, submissionKey, { kind: "task", taskId: task.id });
1939
+ const routed = routeUserSubmission(tx, task, actor, body, effectiveIntent, now, submissionKey, { kind: "task", taskId: task.id }, inputControl);
1747
1940
  return {
1748
1941
  task: routed.task, message: routed.message, actor,
1749
- queuedForLeader: routed.queuedForLeader, feedback: routed.feedback
1942
+ queuedForLeader: routed.queuedForLeader, feedback: routed.feedback, idempotentReplay: false
1750
1943
  };
1751
1944
  }
1752
1945
  if (intent !== undefined) {
@@ -1764,7 +1957,8 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
1764
1957
  });
1765
1958
  const context = {
1766
1959
  ...(target === undefined ? {} : { recipient: target }),
1767
- ...(recipient?.workItemId === undefined ? {} : { workItemId: recipient.workItemId })
1960
+ ...(recipient?.workItemId === undefined ? {} : { workItemId: recipient.workItemId }),
1961
+ ...(inputControl === undefined ? {} : { inputControl })
1768
1962
  };
1769
1963
  const message = actor === "leader"
1770
1964
  ? appendMessage(tx, task.id, body, "role-result", { type: "role", roleName: LEADER_ROLE }, now, context)
@@ -1791,8 +1985,10 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
1791
1985
  else if (queuedForLeader) {
1792
1986
  enqueueWork(tx, leaderMailbox(task.id), actor === "operator" ? "operator-input" : "user-message", now, [messageRef(task.id, message.id)], { source: actor, dedupeKey: `message:${task.id}:${message.id}` });
1793
1987
  }
1794
- return { task, message, actor, queuedForLeader, feedback: undefined };
1988
+ return { task, message, actor, queuedForLeader, feedback: undefined, idempotentReplay: false };
1795
1989
  });
1990
+ if (result.idempotentReplay)
1991
+ return result;
1796
1992
  if (recipient !== undefined) {
1797
1993
  notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
1798
1994
  }
@@ -1802,6 +1998,13 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
1802
1998
  }
1803
1999
  return result;
1804
2000
  }
2001
+ /** The durable author of an already-persisted Message, for an idempotent replay
2002
+ * that must return the same actor label the original send computed. */
2003
+ function replayActor(message) {
2004
+ if (message.author.type === "role")
2005
+ return message.author.roleName === LEADER_ROLE ? "leader" : "role";
2006
+ return message.author.type === "operator" ? "operator" : "user";
2007
+ }
1805
2008
  /**
1806
2009
  * Whether an inbound Message on a Task in this status is delivered to its
1807
2010
  * Leader. Draft qualifies because planning is a Leader conversation that
@@ -1956,6 +2159,8 @@ function taskRoleCommand(args, store, options) {
1956
2159
  return output(unbindTaskRole(rest, store, options));
1957
2160
  if (command === "session")
1958
2161
  return taskRoleSessionCommand(rest, store, options);
2162
+ if (command === "interrupt")
2163
+ return interruptTaskRole(rest, store, options);
1959
2164
  if (command === "view")
1960
2165
  return viewTaskRole(rest, store);
1961
2166
  if (command === "takeover")
@@ -1966,6 +2171,220 @@ function taskRoleCommand(args, store, options) {
1966
2171
  ? "Task role command is required."
1967
2172
  : `Unknown command: task role ${command}`);
1968
2173
  }
2174
+ /**
2175
+ * `interrupt` stops the exact current native Turn through the Provider's own
2176
+ * native cancel, and never through an owned-process kill, restart, or detach
2177
+ * (decision-3 §1/§7). It is a control operation, not a persisted Message, so it
2178
+ * carries no body of its own.
2179
+ *
2180
+ * The optional `--then-message` is the explicit continuation handoff
2181
+ * (decision-3 §4): it names a Message the Leader already saved (typically the
2182
+ * one a failed steer left behind, reusing that Message's original ref) and
2183
+ * claims "deliver this once, in the same Session, after the interrupted Turn
2184
+ * reaches a proven terminal, without the prior queue preempting it." The claim
2185
+ * is registered on the durable Message before the live cancel. Registration is
2186
+ * idempotent per interrupt requestId, and only one continuation may claim a
2187
+ * given target AgentRun; a conflicting second claim is refused, never silently
2188
+ * dropped or duplicated.
2189
+ */
2190
+ function interruptTaskRole(args, store, options) {
2191
+ const usage = "Task role interrupt usage: yui task role interrupt <task> <role> --expected-target <turn> [--then-message <task/message>] [--request-id <id>].";
2192
+ const parsed = parseTail(args, new Set(["--expected-target", "--then-message", "--request-id"]), usage);
2193
+ exactPositionals(parsed.positionals, 2, usage);
2194
+ const expectedTarget = requiredOption(parsed.options, "--expected-target");
2195
+ const thenMessageRef = optionalNonEmptyOption(parsed.options, "--then-message");
2196
+ const requestId = optionalNonEmptyOption(parsed.options, "--request-id");
2197
+ const now = clock(options);
2198
+ const task = requireTask(store, parsed.positionals[0]);
2199
+ const role = requireRole(store, task.id, parsed.positionals[1]);
2200
+ const fingerprint = createHash("sha256").update(JSON.stringify({
2201
+ roleName: role.name, expectedTarget, thenMessageRef: thenMessageRef ?? null
2202
+ })).digest("hex");
2203
+ const operationId = requestId ?? `interrupt-${fingerprint}`;
2204
+ let receiptId = "";
2205
+ const resolved = store.transaction((tx) => {
2206
+ assertTaskOpen(task);
2207
+ assertTaskInputControlAuthority(tx, options.environment, task.id, role.name);
2208
+ const previous = findTaskInterrupt(tx, task.id, operationId);
2209
+ if (previous !== undefined) {
2210
+ if (previous.payload.fingerprint !== fingerprint)
2211
+ throw usageError("Interrupt requestId already names a different target or then input.");
2212
+ return output("Interrupt already recorded; no native request was repeated.\n", {
2213
+ taskId: task.id, roleName: role.name, interrupt: {
2214
+ state: "idempotent-replay", receipt: taskInterruptReceipt(tx, task.id, previous.payload.receiptId)
2215
+ }
2216
+ });
2217
+ }
2218
+ const resolution = resolveTaskInputControl(tx, task.id, role.name, "interrupt", expectedTarget);
2219
+ if (resolution.outcome !== "ready") {
2220
+ return output(`Interrupt not delivered (${resolution.code}: ${resolution.detail}).\n`, { taskId: task.id, roleName: role.name,
2221
+ interrupt: { state: "not-interrupted", code: resolution.code, detail: resolution.detail } });
2222
+ }
2223
+ const priorTarget = tx.listEvents(task.id).find(event => event.type === "input.interrupt-requested"
2224
+ && event.payload.roleName === role.name && event.payload.nativeSessionId === resolution.target.nativeSessionId
2225
+ && event.payload.attemptId === resolution.target.attemptId
2226
+ && !taskInterruptWasRejected(tx, task.id, event.payload.receiptId));
2227
+ if (priorTarget !== undefined) {
2228
+ return output("This exact Turn already has an interrupt request; inspect its original receipt.\n", {
2229
+ taskId: task.id, roleName: role.name, interrupt: {
2230
+ state: "not-interrupted", code: "DELIVERY_UNKNOWN", receiptId: priorTarget.payload.receiptId
2231
+ }
2232
+ });
2233
+ }
2234
+ if (thenMessageRef !== undefined) {
2235
+ const claimResult = registerInterruptThen(tx, task.id, role.name, thenMessageRef, resolution.target, operationId, options, now);
2236
+ if (claimResult !== "claimed") {
2237
+ return output(`Interrupt not delivered (${claimResult.code}: ${claimResult.detail}).\n`, { taskId: task.id, roleName: role.name,
2238
+ interrupt: { state: "not-interrupted", code: claimResult.code, detail: claimResult.detail } });
2239
+ }
2240
+ }
2241
+ receiptId = reserveTaskInterrupt(tx, task.id, operationId, fingerprint, resolution.target, thenMessageRef, now);
2242
+ return resolution.target;
2243
+ });
2244
+ if ("kind" in resolved)
2245
+ return resolved;
2246
+ return {
2247
+ kind: "input-interrupt",
2248
+ taskId: task.id,
2249
+ roleName: role.name,
2250
+ target: resolved,
2251
+ receiptId,
2252
+ ...(thenMessageRef === undefined ? {} : {
2253
+ thenMessageId: taskRecordReference(thenMessageRef, "message", "Then Message reference", options).localId
2254
+ }),
2255
+ output: `Interrupting ${task.id}/${role.name} at Turn ${resolved.nativeTurnId ?? resolved.attemptId}`
2256
+ + `${thenMessageRef === undefined ? "" : `, then delivering ${thenMessageRef} once after a proven terminal`}.\n`
2257
+ };
2258
+ }
2259
+ /**
2260
+ * Bind a saved Message as the one continuation of a target AgentRun. The target
2261
+ * run is the exact interrupted Turn's durable AgentRun; the claim is refused
2262
+ * when the target has no AgentRun (a native-only Turn cannot prove its terminal
2263
+ * for a later same-Session delivery), when the Message is unknown or not a
2264
+ * legal saved input to reuse, or when another continuation already claims that
2265
+ * run.
2266
+ *
2267
+ * The Message being reused must be a legal handoff for this exact interrupt: it
2268
+ * must belong to this Role's current Assignment, must not already have been
2269
+ * delivered or marked undeliverable, and must not still be a live control op.
2270
+ * Its original input identity is preserved as `interruptThen.reusedInput`
2271
+ * provenance rather than erased, so a replay of that requestId keeps resolving
2272
+ * to this same Message and never creates a second input (decision-3 §3).
2273
+ */
2274
+ function registerInterruptThen(store, taskId, roleName, thenMessageRef, target, requestId, options, now) {
2275
+ const targetAttemptId = target.attemptId;
2276
+ const ref = taskRecordReference(thenMessageRef, "message", "Then Message reference", options);
2277
+ if (ref.taskId !== taskId) {
2278
+ return { code: "TARGET_CHANGED", detail: "The then-Message belongs to another Task." };
2279
+ }
2280
+ const message = store.listMessages(taskId).find((entry) => entry.id === ref.localId);
2281
+ if (message === undefined) {
2282
+ return { code: "TARGET_CHANGED", detail: `Then-Message ${thenMessageRef} is unknown.` };
2283
+ }
2284
+ // The claim is keyed on the exact interrupted native Turn (targetAttemptId),
2285
+ // which the resolution proved present under the current Session. An AgentRun
2286
+ // owns the Turn for a Worker/Reviewer Assignment; a Leader's own management or
2287
+ // Draft planning Turn is a real native Turn with NO AgentRun (decision-3 §9,
2288
+ // message-5 gap B). The claim never invents a Run to prove a terminal — the
2289
+ // native Turn is the primary proof, and targetRunId is recorded only when a Run
2290
+ // actually owns the Turn.
2291
+ const active = store.getActiveRun(taskId, roleName);
2292
+ // A synthesized fallback id is an opaque idempotency key (compared only for
2293
+ // equality, never parsed), so it must satisfy the same safe-identity rule as a
2294
+ // caller-supplied requestId: no path separators. Use `:` as the field joiner —
2295
+ // the receiptId carries the `/`-shaped human receipt, this key stays slash-free.
2296
+ const claimRequestId = requestId ?? `interrupt:${taskId}:${roleName}:${targetAttemptId}`;
2297
+ // Idempotent per interrupt requestId: an exact repeat of the same claim (same
2298
+ // request, same native Turn) is a no-op that still authorizes the live cancel.
2299
+ if (message.interruptThen !== undefined) {
2300
+ const prior = findTaskInterrupt(store, taskId, message.interruptThen.requestId);
2301
+ const canReferenceClaim = message.interruptThen.requestId === claimRequestId
2302
+ || (prior !== undefined && taskInterruptWasRejected(store, taskId, prior.payload.receiptId));
2303
+ if (canReferenceClaim && message.interruptThen.notDeliveredReason === undefined
2304
+ && message.interruptThen.targetAttemptId === targetAttemptId
2305
+ && message.interruptThen.targetNativeSessionId === target.nativeSessionId
2306
+ && message.interruptThen.targetAgentId === target.agentId
2307
+ && message.interruptThen.targetAdapterId === target.adapterId
2308
+ && message.interruptThen.targetRoleName === roleName
2309
+ && message.interruptThen.targetAuthorityEpoch === target.authority.epoch
2310
+ && message.interruptThen.targetAuthorityHolderId === target.authority.holderId)
2311
+ return "claimed";
2312
+ return { code: "TARGET_CHANGED",
2313
+ detail: `Message ${thenMessageRef} already claims a continuation of Turn ${message.interruptThen.targetAttemptId}.` };
2314
+ }
2315
+ const inputState = taskMessageInputControlState(message);
2316
+ if (inputState === "accepted" || inputState === "pending" || inputState === "delivery-unknown") {
2317
+ return {
2318
+ code: inputState === "accepted" ? "TARGET_CHANGED" : "DELIVERY_UNKNOWN",
2319
+ detail: `Message ${thenMessageRef} has steer disposition ${inputState}; it cannot be submitted again.`
2320
+ };
2321
+ }
2322
+ // The reused Message must be a legal handoff for this exact interrupt. A
2323
+ // Worker/Reviewer handoff must be addressed to the interrupted Assignment; a
2324
+ // no-Run Leader handoff (its own management/Draft turn) reuses a Leader input,
2325
+ // which is never addressed to a Worker Assignment (no ownerRunId).
2326
+ if (roleName !== LEADER_ROLE) {
2327
+ if (active === null || message.recipient?.roleName !== roleName || message.recipient.ownerRunId !== active.id) {
2328
+ return { code: "TARGET_CHANGED",
2329
+ detail: `Message ${thenMessageRef} is not addressed to the current execution Assignment.` };
2330
+ }
2331
+ }
2332
+ else if (message.recipient?.ownerRunId !== undefined) {
2333
+ return { code: "TARGET_CHANGED",
2334
+ detail: `Message ${thenMessageRef} is addressed to an execution Assignment and cannot be a no-Run ${roleName} handoff.` };
2335
+ }
2336
+ // A Message that already delivered, or was already ruled undeliverable, is not
2337
+ // a fresh input to reuse; reusing it would replay or resurrect a settled fact.
2338
+ if (message.continuation?.runId !== undefined) {
2339
+ return { code: "TARGET_CHANGED", detail: `Message ${thenMessageRef} was already delivered by ${message.continuation.runId}.` };
2340
+ }
2341
+ if (message.continuation?.notDeliveredReason !== undefined) {
2342
+ return { code: "TARGET_CHANGED",
2343
+ detail: `Message ${thenMessageRef} was already settled not-delivered (${message.continuation.notDeliveredReason}).` };
2344
+ }
2345
+ // Only one continuation may claim a given target native Turn.
2346
+ const existing = store.listMessages(taskId).find((entry) => entry.interruptThen?.targetAttemptId === targetAttemptId && entry.id !== message.id);
2347
+ if (existing !== undefined) {
2348
+ return { code: "TARGET_CHANGED",
2349
+ detail: `Turn ${targetAttemptId} is already the terminal target of Message ${existing.id}.` };
2350
+ }
2351
+ // Reference the new op without rewriting the original input facts: keep the
2352
+ // Message's history, move any steer identity into reusedInput provenance (so a
2353
+ // replay of that requestId still resolves here, never a second input), and
2354
+ // clear only the live steer action so the handoff is deliverable by the queued
2355
+ // continuation path after a proven terminal.
2356
+ const { inputControl, ...rest } = message;
2357
+ store.updateMessage(taskId, { ...rest,
2358
+ interruptThen: { requestId: claimRequestId, targetAttemptId,
2359
+ targetRoleName: roleName, targetNativeSessionId: target.nativeSessionId,
2360
+ targetAgentId: target.agentId, targetAdapterId: target.adapterId,
2361
+ targetAuthorityEpoch: target.authority.epoch, targetAuthorityHolderId: target.authority.holderId,
2362
+ ...(target.nativeTurnId === undefined ? {} : { targetNativeTurnId: target.nativeTurnId }),
2363
+ ...(active === null ? {} : { targetRunId: active.id }),
2364
+ ...(inputControl === undefined ? {} : { reusedInput: inputControl }) } });
2365
+ recordTaskEvent(store, taskId, "message.interrupt-then-claimed", {
2366
+ messageId: message.id, roleName, targetAttemptId,
2367
+ ...(active === null ? {} : { targetRunId: active.id }),
2368
+ ...(inputControl === undefined ? {} : { reusedRequestId: inputControl.requestId })
2369
+ }, now);
2370
+ // Release trigger for a no-Run Leader handoff (decision-3 §4/§9). A
2371
+ // Worker/Reviewer claim (active !== null) is released by the reconcile loop's
2372
+ // unconditional prepareMessageContinuations pass, driven by the owning
2373
+ // AgentRun's terminal — no extra wake is needed. A no-Run Leader turn owns no
2374
+ // AgentRun and is never surfaced by that push path; it is delivered only by
2375
+ // being woken to read its own Context. The interrupt itself (and, when the
2376
+ // handoff reuses a Leader self-steer, wakePolicy "none") enqueues no wake, so
2377
+ // without this the claim would be structurally unreleasable: listPendingWakeups
2378
+ // never selects the Task and the busy-gated leader mailbox is never consulted.
2379
+ // Enqueue that wake now, keyed to the claimed Message so an idempotent repeat of
2380
+ // the same claim does not stack a second one; claimLeaderNotification holds it
2381
+ // until the interrupted native Turn reaches its proven terminal, and only then
2382
+ // does the Leader read the Context that surfaces this handoff.
2383
+ if (roleName === LEADER_ROLE) {
2384
+ enqueueWork(store, leaderMailbox(taskId), "interrupt-then", now, [messageRef(taskId, message.id)], { source: "interrupt-then", dedupeKey: `interrupt-then:${taskId}:${message.id}` });
2385
+ }
2386
+ return "claimed";
2387
+ }
1969
2388
  function taskRoleSessionCommand(args, store, options) {
1970
2389
  const [command, ...rest] = args;
1971
2390
  if (command === "inspect") {
@@ -1987,6 +2406,7 @@ function taskRoleSessionCommand(args, store, options) {
1987
2406
  // means there was nothing an Agent reported, and the one-line label above
1988
2407
  // still states which of the "no value" cases applies.
1989
2408
  const runConfiguration = active === null ? undefined : options.liveRunConfiguration;
2409
+ const host = options.liveHostObservations?.[role.name];
1990
2410
  const runConfigurationDetail = renderAgentRunConfiguration(runConfiguration);
1991
2411
  return output(active === null
1992
2412
  ? `No attached Session exists for ${task.id}/${role.name}.\n`
@@ -2000,6 +2420,7 @@ function taskRoleSessionCommand(args, store, options) {
2000
2420
  `Native id: ${active.nativeSessionId}`,
2001
2421
  `Session: ${active.status}${active.endReason === undefined ? "" : `/${active.endReason}`}`,
2002
2422
  `AgentRun: ${binding?.run?.status ?? "none"}`,
2423
+ ...(host === undefined ? [] : [`Host reporting: ${taskRoleHostDiagnostic(host)}`]),
2003
2424
  `Run configuration: ${agentRunConfigurationLabel(runConfiguration)}`
2004
2425
  ].join("\n") + "\n"
2005
2426
  + `\n${renderRoleLaunchComparison(role, active.effective)}\n`
@@ -2008,6 +2429,7 @@ function taskRoleSessionCommand(args, store, options) {
2008
2429
  role,
2009
2430
  session: active,
2010
2431
  providerBinding: binding,
2432
+ ...(host === undefined ? {} : { host }),
2011
2433
  ...(runConfiguration === undefined ? {} : { runConfiguration })
2012
2434
  });
2013
2435
  }
@@ -2051,9 +2473,6 @@ function taskRoleSessionCommand(args, store, options) {
2051
2473
  const now = clock(options);
2052
2474
  const request = store.transaction((tx) => {
2053
2475
  const task = requireTask(tx, parsed.positionals[0]);
2054
- if (!["draft", "active", "completed", "cancelled"].includes(task.status)) {
2055
- throw usageError(`Task Role Session stop is unavailable for an archived Task: ${task.id}.`, usage);
2056
- }
2057
2476
  const actor = taskActor(tx, options, task.id);
2058
2477
  const role = requireRole(tx, task.id, parsed.positionals[1]);
2059
2478
  if (actor === "leader" && role.name === LEADER_ROLE) {
@@ -2172,14 +2591,14 @@ function addTaskRole(args, store, options) {
2172
2591
  `Runtime source: ${runtimeSource}`,
2173
2592
  `Agent: ${result.role.activeAgentId}/${result.binding.adapterId}`,
2174
2593
  `Model: ${result.binding.config.model ?? "CLI default"}; effort: ${result.binding.config.effort ?? "CLI default"}; permission: ${result.binding.config.permission.strategy}`,
2175
- "Next: create a WorkItem and start this Role when it has assigned work."
2594
+ "Next: dispatch an assigned WorkItem, or request a Task-final Review with this Role; a Review needs no WorkItem."
2176
2595
  ].join("\n").concat("\n");
2177
2596
  }
2178
2597
  function listTaskRoles(args, store, options) {
2179
2598
  exactPositionals(args, 1, "Task role list usage: yui task role list <task>.");
2180
2599
  const task = requireTask(store, args[0]);
2181
2600
  const roles = store.listRoles(task.id);
2182
- const statuses = inspectTaskRoleRuntimeStatuses(task.id, roles, store, options.runtime?.inspectTaskRolePanes?.(task.id) ?? [], options.now?.() ?? new Date());
2601
+ const statuses = inspectTaskRoleRuntimeStatuses(task.id, roles, store, options.runtime?.inspectTaskRolePanes?.(task.id) ?? [], options.now?.() ?? new Date()).map(status => withTaskRoleHostObservation(status, options.liveHostObservations?.[status.roleName]));
2183
2602
  if (statuses.length === 0)
2184
2603
  return output("No roles assigned.\n", { roles: statuses });
2185
2604
  return output(`${renderTable(`Task roles: ${task.id}`, [
@@ -2209,7 +2628,8 @@ function taskRoleStatus(args, store, options) {
2209
2628
  const [status] = inspectTaskRoleRuntimeStatuses(task.id, [role], store, options.runtime?.inspectTaskRolePanes?.(task.id) ?? [], options.now?.() ?? new Date());
2210
2629
  if (status === undefined)
2211
2630
  throw roleNotFound(role.name);
2212
- return output(renderTaskRoleRuntimeStatus(status), { role: status });
2631
+ const observed = withTaskRoleHostObservation(status, options.liveHostObservations?.[role.name]);
2632
+ return output(renderTaskRoleRuntimeStatus(observed), { role: observed });
2213
2633
  }
2214
2634
  function showTaskRole(args, store) {
2215
2635
  exactPositionals(args, 2, "Task role show usage: yui task role show <task> <role>.");
@@ -2956,6 +3376,17 @@ function updateWork(args, store, options) {
2956
3376
  workItem: result.item
2957
3377
  });
2958
3378
  }
3379
+ /** Shared with CLI preflight so a refused dispatch cannot prepare workspaces. */
3380
+ export function requireWorkItemAssignee(item) {
3381
+ if (item.assignee === undefined) {
3382
+ throw usageError(`Work Item has no Task Role assignee: ${item.id}. `
3383
+ + `The Task Leader can execute it directly without dispatch: `
3384
+ + `yui task work update ${item.taskId}/${item.id} running. `
3385
+ + (item.writeProjectIds.length === 0 ? "" :
3386
+ `For code, first use yui task work isolate ${item.taskId}/${item.id}.`));
3387
+ }
3388
+ return item.assignee;
3389
+ }
2959
3390
  function dispatchWork(args, store, options) {
2960
3391
  const usage = "Task work dispatch usage: yui task work dispatch <task>/<work> [--input <text>] [--lane-role <role> ...].";
2961
3392
  const parsed = parseMultiValueTail(args, new Set(["--input"]), new Set(["--lane-role"]), usage);
@@ -2969,11 +3400,8 @@ function dispatchWork(args, store, options) {
2969
3400
  if (task.status !== "active")
2970
3401
  throw usageError(inactiveTaskMessage(task, "dispatch"));
2971
3402
  assertTaskExecutionEnabled(task, "dispatching work");
2972
- if (item.assignee === undefined) {
2973
- throw usageError(`Work Item has no Task Role assignee: ${item.id}. `
2974
- + `The Task Leader must run "yui task work update ${item.id} running" and execute it directly.`);
2975
- }
2976
- const lanePlan = planReplicatedWorkItemLanes(item.assignee, requestedLaneRoles, `execution-group-${tx.peekNextRunId(task.id)}`);
3403
+ const assignee = requireWorkItemAssignee(item);
3404
+ const lanePlan = planReplicatedWorkItemLanes(assignee, requestedLaneRoles, `execution-group-${tx.peekNextRunId(task.id)}`);
2977
3405
  const currentGroup = currentWorkItemExecutionGroup(item);
2978
3406
  if (item.status !== "open") {
2979
3407
  throw usageError(`Work item ${item.id} cannot be dispatched from ${item.status}.`);
@@ -3010,7 +3438,7 @@ function dispatchWork(args, store, options) {
3010
3438
  const rawInput = trimmed(parsed.options.get("--input")) ?? item.objective;
3011
3439
  let workItemForDispatch = prepareWorkItemDispatch(item, now);
3012
3440
  if (lanePlan.roles.length === 0) {
3013
- const role = requireRole(tx, task.id, item.assignee);
3441
+ const role = requireRole(tx, task.id, assignee);
3014
3442
  if (tx.getActiveRun(task.id, role.name) !== null) {
3015
3443
  throw usageError(`${task.id}/${role.name} already has an active run.`);
3016
3444
  }
@@ -5821,7 +6249,7 @@ function activeReviewRoundForCandidate(store, item, candidate) {
5821
6249
  }
5822
6250
  function createTaskRole(store, task, roleName, explicitAgentId, now, sourceGlobalRoleName) {
5823
6251
  const workspace = task.status === "draft"
5824
- ? join(`${store.rootDirectory()}.task-runtimes`, "planning", task.id)
6252
+ ? planningRuntimeCwd(store.rootDirectory(), task.id)
5825
6253
  : task.cwd ?? store.getConfig().defaultWorkspace ?? process.cwd();
5826
6254
  if (explicitAgentId === undefined) {
5827
6255
  const sourceRoleName = sourceGlobalRoleName
@@ -5851,7 +6279,7 @@ function requireAgentProfile(store, id) {
5851
6279
  }
5852
6280
  function createTaskRoleFromAgentBinding(store, task, roleName, binding, now) {
5853
6281
  const workspace = task.status === "draft"
5854
- ? join(`${store.rootDirectory()}.task-runtimes`, "planning", task.id)
6282
+ ? planningRuntimeCwd(store.rootDirectory(), task.id)
5855
6283
  : task.cwd ?? store.getConfig().defaultWorkspace ?? process.cwd();
5856
6284
  return createRole(task.id, roleName, [binding], binding.agentId, workspace, now);
5857
6285
  }