@zq-silk/yui 0.15.8 → 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.
- package/ARCHITECTURE.md +2 -0
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +211 -14
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/cli/commandCatalog.js +40 -16
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +403 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/operatorCommands.js +33 -2
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActivationCommands.js +22 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +846 -155
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +28 -16
- package/dist/context/taskContext.js +64 -5
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +11 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/kernel/builtinCapabilities.js +32 -24
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +423 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/plugins/pluginService.js +11 -3
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/projectResource.js +0 -48
- package/dist/resources/projectResourceService.js +3 -81
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +29 -16
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +173 -7
- package/dist/storage/sqliteStore.js +41 -22
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/taskActivation.js +26 -0
- package/dist/task/taskActivationService.js +85 -69
- package/dist/task/taskSubmission.js +236 -0
- package/dist/web/assets/client/app.js +58 -2
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +202 -7
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +135 -4
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +225 -8
- package/dist/workItem/workItem.js +14 -10
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/agent-result-consumption.md +2 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +2 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +44 -32
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -79
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +2 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +2 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +320 -217
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +2 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +41 -0
- package/docs/release-workflow.zh-CN.md +266 -0
- package/docs/roles-and-configuration.md +2 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +225 -1
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -57
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +2 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +2 -0
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +26 -0
- package/docs/testing/verification-levels.zh-CN.md +80 -0
- package/i18n/README.zh-CN.md +199 -10
- package/package.json +2 -1
- package/skills/yui-leader/SKILL.md +88 -331
- package/skills/yui-leader/references/execution.md +405 -0
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/task-plugins.md +8 -4
- package/skills/yui-operator/SKILL.md +22 -4
- package/skills/yui-runtime/SKILL.md +27 -0
- 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 {
|
|
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,8 +20,10 @@ 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 } 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
|
+
import { TASK_PLANNING_ENTERED_EVENT, decideSubmissionRouting, describeSubmissionFeedback, draftActivationState, draftHasEnteredPlanning, normalizeSubmissionIntent, sameSubmissionTarget } from "../task/taskSubmission.js";
|
|
26
|
+
import { recordTaskActivationRequestInTransaction, taskActivationOperationRef } from "../task/taskActivationService.js";
|
|
25
27
|
import { cancelInputRequest } from "../input/inputRequest.js";
|
|
26
28
|
import { retireExactActiveRun, terminalizeExactTaskRun, validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
|
|
27
29
|
import { copyGlobalRoleToTaskRole, createRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateRole } from "../role/role.js";
|
|
@@ -34,7 +36,7 @@ import { createTaskBrief, updateTaskBrief } from "../brief/taskBrief.js";
|
|
|
34
36
|
import { createDecision, supersedeDecision } from "../decision/decision.js";
|
|
35
37
|
import { createMilestone } from "../milestone/milestone.js";
|
|
36
38
|
import { runPublicationCommand } from "./taskPublicationCommands.js";
|
|
37
|
-
import { runTaskActivationCommand } from "./taskActivationCommands.js";
|
|
39
|
+
import { runTaskActivationCommand, nonLeaderActivationIdentity } from "./taskActivationCommands.js";
|
|
38
40
|
import { assertTaskRemoteDeliveryProof, projectTaskRemoteDeliveryFromStore, renderTaskRemoteDelivery, runTaskRemoteDeliveryCommand } from "./taskRemoteDeliveryCommand.js";
|
|
39
41
|
import { enqueueRoleRunDispatch, enqueueWork, settleExactWorkExecution } from "../coordination/workMailboxQueue.js";
|
|
40
42
|
import { completeProcessing } from "../coordination/workMailbox.js";
|
|
@@ -44,6 +46,8 @@ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.j
|
|
|
44
46
|
import { addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, taskOwnsManagedWorkspace, updateTaskMetadata } from "../task/task.js";
|
|
45
47
|
import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
|
|
46
48
|
import { projectCompletionReadiness } from "../task/completionReadiness.js";
|
|
49
|
+
import { StorageConflictError } from "../storage/taskStore.js";
|
|
50
|
+
import { planningRuntimeCwd } from "../storage/homeLayout.js";
|
|
47
51
|
import { requireResolvedAgentProfileRuntime } from "../profile/agentProfileRuntime.js";
|
|
48
52
|
import { assertProjectActive, resolveProject } from "../repository/project.js";
|
|
49
53
|
import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, editWorkItemDefinition, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, prepareWorkItemDispatch, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
@@ -61,17 +65,19 @@ import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./rol
|
|
|
61
65
|
import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
|
|
62
66
|
import { runTaskContextCommand } from "./taskContextCommand.js";
|
|
63
67
|
import { listContextMessages } from "../context/taskContext.js";
|
|
64
|
-
import { createProjectResources
|
|
65
|
-
import {
|
|
68
|
+
import { createProjectResources } from "../resources/projectResourceService.js";
|
|
69
|
+
import { isGitArtifactRefString, parseGitArtifactRef } from "../artifacts/gitArtifactRef.js";
|
|
66
70
|
import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
|
|
67
71
|
import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
|
|
68
|
-
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";
|
|
69
73
|
import { assertNoOpenInputRequests, openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
|
|
70
74
|
import { runGrantCommand } from "./grantCommands.js";
|
|
71
75
|
import { runWorkflowCommand } from "./workflowCommands.js";
|
|
72
|
-
import { taskLocalActor as resolveTaskLocalActor, assertTaskDeliveryAuthority } from "./taskActor.js";
|
|
76
|
+
import { taskLocalActor as resolveTaskLocalActor, assertTaskDeliveryAuthority, assertTaskInputControlAuthority } from "./taskActor.js";
|
|
73
77
|
import { currentManagedRuntime, resolveManagedTaskReader } from "../runtime/managedCaller.js";
|
|
74
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";
|
|
75
81
|
import { enqueueOperatorEvent } from "../scheduler/operatorEvent.js";
|
|
76
82
|
import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
|
|
77
83
|
import { renderWakeReason, wakeReason } from "../scheduler/wakeReason.js";
|
|
@@ -303,44 +309,6 @@ export function runTaskCommand(args, store, options = {}) {
|
|
|
303
309
|
assertTaskDeliveryAuthority(store, options.environment, options.environment.YUI_TASK_ID);
|
|
304
310
|
}
|
|
305
311
|
switch (command) {
|
|
306
|
-
case "artifact": {
|
|
307
|
-
const [action, taskId, value] = rest;
|
|
308
|
-
if (!taskId || !["list", "show", "save"].includes(action)
|
|
309
|
-
|| rest.length !== (action === "list" ? 2 : 3)) {
|
|
310
|
-
throw usageError("Usage: yui task artifact list <task> | show <task> <artifact-id> | save <task> <artifact-json>");
|
|
311
|
-
}
|
|
312
|
-
if (options.environment?.YUI_SESSION_SCOPE === "task" && options.environment.YUI_TASK_ID !== taskId) {
|
|
313
|
-
throw usageError("Artifact is outside the managed Task scope.");
|
|
314
|
-
}
|
|
315
|
-
requireTask(store, taskId);
|
|
316
|
-
let data;
|
|
317
|
-
if (action === "list")
|
|
318
|
-
data = store.listArtifacts(taskId).map(artifactSummary);
|
|
319
|
-
else if (action === "show") {
|
|
320
|
-
data = store.getArtifact(taskId, value);
|
|
321
|
-
if (data === null)
|
|
322
|
-
throw usageError("Artifact not found in this Task.");
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
325
|
-
taskActor(store, options, taskId);
|
|
326
|
-
let parsed;
|
|
327
|
-
try {
|
|
328
|
-
parsed = JSON.parse(value);
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
throw usageError("Artifact input must be JSON.");
|
|
332
|
-
}
|
|
333
|
-
let input;
|
|
334
|
-
try {
|
|
335
|
-
input = validateArtifactInput(parsed);
|
|
336
|
-
}
|
|
337
|
-
catch (error) {
|
|
338
|
-
throw usageError(`Artifact input is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
-
}
|
|
340
|
-
data = createProjectResources(store).saveArtifact(taskId, input);
|
|
341
|
-
}
|
|
342
|
-
return output(JSON.stringify(data, null, 2), data);
|
|
343
|
-
}
|
|
344
312
|
case "create": return createTaskCommand(rest, store, options);
|
|
345
313
|
case "update": return output(updateTaskCommand(rest, store, options));
|
|
346
314
|
case "list": return listTaskCommand(rest, store);
|
|
@@ -352,7 +320,7 @@ export function runTaskCommand(args, store, options = {}) {
|
|
|
352
320
|
case "activation": return runTaskActivationCommand(rest, store, options);
|
|
353
321
|
case "complete": return completeTaskCommand(rest, store, options);
|
|
354
322
|
case "reopen": return output(reopenTaskCommand(rest, store, options));
|
|
355
|
-
case "archive": return
|
|
323
|
+
case "archive": return archiveTaskCommand(rest, store, options);
|
|
356
324
|
case "retire": return retireTaskCommand(rest, store, options);
|
|
357
325
|
case "cancel": return cancelTaskCommand(rest, store, options);
|
|
358
326
|
case "reconcile": return output(reconcileTaskCommand(rest, store, options));
|
|
@@ -580,27 +548,274 @@ export function updateTaskMetadataCommand(store, taskId, patch, options = {}) {
|
|
|
580
548
|
notifyMailbox(options.runtime, taskMailbox(result.id), result.id);
|
|
581
549
|
return result;
|
|
582
550
|
}
|
|
583
|
-
|
|
551
|
+
function routeUserSubmission(tx, task, actor, body, intent, now, submissionKey, target, inputControl) {
|
|
552
|
+
const kind = actor;
|
|
553
|
+
const author = actor === "operator"
|
|
554
|
+
? { type: "operator" }
|
|
555
|
+
: { type: "user" };
|
|
556
|
+
// §2.3 keyed idempotency: a retry is detected by reading an existing keyed
|
|
557
|
+
// Message, and its original disposition is reproduced from the receipt that
|
|
558
|
+
// Message carries — never recomputed from current state. A matching key with
|
|
559
|
+
// matching input and target replays; any mismatch conflicts.
|
|
560
|
+
if (submissionKey !== undefined) {
|
|
561
|
+
const prior = tx.listMessages(task.id).find((message) => message.submissionKey === submissionKey);
|
|
562
|
+
if (prior !== undefined) {
|
|
563
|
+
return replayKeyedSubmission(task, prior, kind, body, intent, target);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Save first, recording intent and key, so the "saved" facet holds regardless of
|
|
567
|
+
// how routing then resolves (§2.5). Intent is stored, never re-read from the body.
|
|
568
|
+
const message = appendMessage(tx, task.id, body, kind, author, now, {
|
|
569
|
+
intent,
|
|
570
|
+
...(submissionKey === undefined ? {} : { submissionKey }),
|
|
571
|
+
...(inputControl === undefined ? {} : { inputControl })
|
|
572
|
+
});
|
|
573
|
+
// Re-read phase and activation inside this same transaction: this is the point
|
|
574
|
+
// the race is decided at (§2.3).
|
|
575
|
+
const enteredPlanning = draftHasEnteredPlanning(tx, task);
|
|
576
|
+
const activation = draftActivationState(task.activationRequest);
|
|
577
|
+
const routing = decideSubmissionRouting({
|
|
578
|
+
intent,
|
|
579
|
+
status: task.status,
|
|
580
|
+
enteredPlanning,
|
|
581
|
+
activation,
|
|
582
|
+
executionEnabled: task.executionGate.state === "enabled",
|
|
583
|
+
// develop adopts no extra resource by guessing: the workspace is still built
|
|
584
|
+
// from the Task's own Project bindings when it activates (§2.3).
|
|
585
|
+
developEnvironmentPlan: { kind: "empty" }
|
|
586
|
+
});
|
|
587
|
+
let queuedForLeader = false;
|
|
588
|
+
let activationRef;
|
|
589
|
+
let activationFailure;
|
|
590
|
+
switch (routing.kind) {
|
|
591
|
+
case "record":
|
|
592
|
+
case "planned-needs-manual-activation":
|
|
593
|
+
case "activation-blocked-execution-stopped":
|
|
594
|
+
// Save only. No Leader wake, no planning, no activation.
|
|
595
|
+
break;
|
|
596
|
+
case "await-activation":
|
|
597
|
+
// The submission is a post-activation input or a report against an existing
|
|
598
|
+
// request; it acts on nothing itself but surfaces the exact reference.
|
|
599
|
+
activationRef = task.activationRequest === undefined
|
|
600
|
+
? undefined
|
|
601
|
+
: taskActivationOperationRef(task.id, task.activationRequest.operation.requestId);
|
|
602
|
+
if (routing.state === "failed") {
|
|
603
|
+
activationFailure = task.activationRequest?.outcome;
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
case "enter-planning":
|
|
607
|
+
// The one place the shared service itself writes the planning-entered fact,
|
|
608
|
+
// in the same transaction as the message (§2.2 derivation source (a)).
|
|
609
|
+
recordTaskEvent(tx, task.id, TASK_PLANNING_ENTERED_EVENT, {
|
|
610
|
+
messageId: message.id,
|
|
611
|
+
intent
|
|
612
|
+
}, now);
|
|
613
|
+
enqueueWork(tx, leaderMailbox(task.id), submissionEnqueueReason(actor), now, [messageRef(task.id, message.id)], { source: actor, dedupeKey: `message:${task.id}:${message.id}` });
|
|
614
|
+
queuedForLeader = true;
|
|
615
|
+
break;
|
|
616
|
+
case "continue-planning":
|
|
617
|
+
case "active-context":
|
|
618
|
+
enqueueWork(tx, leaderMailbox(task.id), submissionEnqueueReason(actor), now, [messageRef(task.id, message.id)], { source: actor, dedupeKey: `message:${task.id}:${message.id}` });
|
|
619
|
+
queuedForLeader = true;
|
|
620
|
+
break;
|
|
621
|
+
case "activate": {
|
|
622
|
+
// Record the activation in-transaction, then queue only activation
|
|
623
|
+
// processing. The requestId is derived from the message so a retried
|
|
624
|
+
// submission cannot mint a second request.
|
|
625
|
+
const identity = nonLeaderActivationIdentity(tx, actor);
|
|
626
|
+
recordTaskActivationRequestInTransaction(tx, {
|
|
627
|
+
taskId: task.id,
|
|
628
|
+
requestId: `submit-${message.id}`,
|
|
629
|
+
actorId: identity.actorId,
|
|
630
|
+
authorityRef: identity.authorityRef,
|
|
631
|
+
environmentPlan: routing.environmentPlan,
|
|
632
|
+
origin: "submit-develop"
|
|
633
|
+
}, now);
|
|
634
|
+
activationRef = taskActivationOperationRef(task.id, `submit-${message.id}`);
|
|
635
|
+
enqueueWork(tx, taskMailbox(task.id), "activation-requested", now, [taskRef(task.id)]);
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
default: {
|
|
639
|
+
const exhaustive = routing;
|
|
640
|
+
throw new Error(`Unhandled submission routing: ${JSON.stringify(exhaustive)}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const feedback = describeSubmissionFeedback({
|
|
644
|
+
taskId: task.id,
|
|
645
|
+
messageId: message.id,
|
|
646
|
+
status: task.status,
|
|
647
|
+
enteredPlanning,
|
|
648
|
+
activationState: activation,
|
|
649
|
+
routing,
|
|
650
|
+
...(activationRef === undefined ? {} : { activationRef }),
|
|
651
|
+
...(activationFailure === undefined ? {} : { activationFailure })
|
|
652
|
+
});
|
|
653
|
+
// §2.3 receipt: a keyed submission records the disposition it actually received
|
|
654
|
+
// (routing + feedback) and the target its key bound to, in this same
|
|
655
|
+
// transaction, so a later retry reproduces exactly this outcome rather than
|
|
656
|
+
// re-deriving one from a phase or activation that has since changed.
|
|
657
|
+
if (submissionKey !== undefined && target !== undefined) {
|
|
658
|
+
const receipted = withSubmissionReceipt(message, { target, routing, feedback });
|
|
659
|
+
tx.updateMessage(task.id, receipted);
|
|
660
|
+
return { task, message: receipted, routing, feedback, queuedForLeader };
|
|
661
|
+
}
|
|
662
|
+
return { task, message, routing, feedback, queuedForLeader };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Reproduce a keyed submission's original outcome from the receipt it recorded
|
|
666
|
+
* (task-32 §2.3), never by recomputing from the Task's current state. The prior
|
|
667
|
+
* Message's frozen {@link SubmissionReceipt} names the routing it received and the
|
|
668
|
+
* §2.5 feedback it returned, so a gate enabled or an activation cancelled after
|
|
669
|
+
* the fact can no longer fabricate a different disposition. The replay writes
|
|
670
|
+
* nothing — any Leader wake or activation request the original made already
|
|
671
|
+
* exists — and preserves the original Task/Message/routing/request references
|
|
672
|
+
* exactly.
|
|
673
|
+
*
|
|
674
|
+
* Same key with a different normalized input (kind, body or intent) or a
|
|
675
|
+
* different target is a conflict, never a silent overwrite or a cross-target
|
|
676
|
+
* replay. A prior keyed Message that carries no receipt (an old client, before
|
|
677
|
+
* receipts existed) also conflicts rather than being replayed from a rebuilt
|
|
678
|
+
* disposition.
|
|
679
|
+
*/
|
|
680
|
+
function replayKeyedSubmission(task, prior, kind, body, intent, target) {
|
|
681
|
+
const receipt = prior.submissionReceipt;
|
|
682
|
+
if (prior.kind !== kind
|
|
683
|
+
|| prior.body !== body
|
|
684
|
+
|| normalizeSubmissionIntent(prior.intent) !== intent
|
|
685
|
+
|| receipt === undefined
|
|
686
|
+
|| (target !== undefined && !sameSubmissionTarget(receipt.target, target))) {
|
|
687
|
+
throw new StorageConflictError(`Submission key ${prior.submissionKey} was already used for a different submission on ${task.id}/${prior.id}.`);
|
|
688
|
+
}
|
|
689
|
+
// Reproduce the recorded disposition verbatim; a retry acts on nothing itself.
|
|
690
|
+
return {
|
|
691
|
+
task,
|
|
692
|
+
message: prior,
|
|
693
|
+
routing: receipt.routing,
|
|
694
|
+
feedback: receipt.feedback,
|
|
695
|
+
queuedForLeader: false
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/** The mailbox reason a submission wake carries, preserving the existing
|
|
699
|
+
* operator-input / user-message vocabulary the Controller already understands. */
|
|
700
|
+
function submissionEnqueueReason(actor) {
|
|
701
|
+
return actor === "operator" ? "operator-input" : "user-message";
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Render the §2.5 feedback as CLI text, one facet per line, so the user always
|
|
705
|
+
* sees the save, the phase, planning, activation, delivery and next step as
|
|
706
|
+
* separate statements — never a single "started". Web and capability callers
|
|
707
|
+
* return {@link SubmissionFeedback} structurally instead of this text.
|
|
708
|
+
*/
|
|
709
|
+
function renderSubmissionFeedback(feedback) {
|
|
710
|
+
const lines = [
|
|
711
|
+
`Saved message ${feedback.saved.messageId} to ${feedback.saved.taskId}.`,
|
|
712
|
+
`Phase: ${SUBMISSION_PHASE_LABEL[feedback.phase]}.`
|
|
713
|
+
];
|
|
714
|
+
if (feedback.planning !== "none") {
|
|
715
|
+
lines.push(`Planning: ${feedback.planning === "entered"
|
|
716
|
+
? "started; the Leader is queued to plan"
|
|
717
|
+
: "continued; the Leader is queued"}.`);
|
|
718
|
+
}
|
|
719
|
+
if (feedback.activation !== "none") {
|
|
720
|
+
lines.push(`Activation: ${SUBMISSION_ACTIVATION_LABEL[feedback.activation]}.`);
|
|
721
|
+
}
|
|
722
|
+
if (feedback.delivery === "queued" && feedback.planning === "none") {
|
|
723
|
+
lines.push("Delivery: queued to the Leader.");
|
|
724
|
+
}
|
|
725
|
+
const nextStep = feedback.nextStep;
|
|
726
|
+
if (nextStep !== undefined)
|
|
727
|
+
lines.push(`Next: ${renderSubmissionNextStep(nextStep)}`);
|
|
728
|
+
return `${lines.join("\n")}\n`;
|
|
729
|
+
}
|
|
730
|
+
const SUBMISSION_PHASE_LABEL = {
|
|
731
|
+
active: "active",
|
|
732
|
+
"draft-planning": "Draft, in planning",
|
|
733
|
+
"draft-unplanned": "Draft, not yet in planning"
|
|
734
|
+
};
|
|
735
|
+
const SUBMISSION_ACTIVATION_LABEL = {
|
|
736
|
+
none: "none",
|
|
737
|
+
requested: "requested; activation is queued",
|
|
738
|
+
pending: "already pending; this input waits for it",
|
|
739
|
+
failed: "the previous request failed",
|
|
740
|
+
"manual-required": "already planned; activate explicitly to develop",
|
|
741
|
+
"execution-stopped": "not requested; execution is stopped"
|
|
742
|
+
};
|
|
743
|
+
function renderSubmissionNextStep(step) {
|
|
744
|
+
switch (step.kind) {
|
|
745
|
+
case "activate-manually":
|
|
746
|
+
return `activate it explicitly with "yui task activate ${step.taskId}".`;
|
|
747
|
+
case "start-execution":
|
|
748
|
+
return `start execution with "yui task execution start ${step.taskId}", then submit develop again.`;
|
|
749
|
+
case "await-pending-activation":
|
|
750
|
+
return `wait for the pending activation ${step.activationRef}.`;
|
|
751
|
+
case "resolve-failed-activation":
|
|
752
|
+
return `retry with a new activation request or cancel ${step.activationRef} (failure: ${step.failure}).`;
|
|
753
|
+
default: {
|
|
754
|
+
const exhaustive = step;
|
|
755
|
+
throw new Error(`Unhandled submission next step: ${JSON.stringify(exhaustive)}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** Parse and validate the CLI `--intent` option. Absent leaves it undefined so
|
|
760
|
+
* the shared service applies the discuss default (task-32 §2.5). */
|
|
761
|
+
function parseSubmissionIntentOption(raw, usage) {
|
|
762
|
+
if (raw === undefined)
|
|
763
|
+
return undefined;
|
|
764
|
+
if (TASK_SUBMISSION_INTENTS.includes(raw)) {
|
|
765
|
+
return raw;
|
|
766
|
+
}
|
|
767
|
+
throw usageError(`--intent must be one of ${TASK_SUBMISSION_INTENTS.join(", ")}: ${raw}.`, usage);
|
|
768
|
+
}
|
|
769
|
+
export function submitOperatorMessage(body, taskId, store, options = {}, intent, submissionKey) {
|
|
584
770
|
const now = clock(options);
|
|
771
|
+
const effectiveIntent = normalizeSubmissionIntent(intent);
|
|
585
772
|
const result = store.transaction((tx) => {
|
|
586
773
|
if (taskId !== undefined) {
|
|
774
|
+
// Addressed submit: the key is scoped to this Task and dedups within it, so
|
|
775
|
+
// no other Task is read (§2.3). Its target is this Task.
|
|
587
776
|
const task = requireTask(tx, taskId);
|
|
588
777
|
assertTaskOpen(task);
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
778
|
+
const routed = routeUserSubmission(tx, task, "operator", body, effectiveIntent, now, submissionKey, { kind: "task", taskId: task.id });
|
|
779
|
+
return { ...routed, created: false };
|
|
780
|
+
}
|
|
781
|
+
// Task-less submit: the key is scoped to "create a new Task". A retry must
|
|
782
|
+
// locate the Draft the original create produced — not any same-key Message on
|
|
783
|
+
// an addressed Task — and a key already bound to a specific Task cannot be
|
|
784
|
+
// reused to create (§2.3 "同 key 不同目标冲突").
|
|
785
|
+
if (submissionKey !== undefined) {
|
|
786
|
+
const lookup = findSubmissionKeyCreate(tx, submissionKey);
|
|
787
|
+
if (lookup.kind === "create") {
|
|
788
|
+
const task = requireTask(tx, lookup.taskId);
|
|
789
|
+
const routed = routeUserSubmission(tx, task, "operator", body, effectiveIntent, now, submissionKey, { kind: "create" });
|
|
790
|
+
return { ...routed, created: false };
|
|
791
|
+
}
|
|
792
|
+
if (lookup.kind === "other-target") {
|
|
793
|
+
throw new StorageConflictError(`Submission key ${submissionKey} is already bound to a specific Task; a task-less create cannot reuse it.`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const createdAgg = createTaskAggregate(tx, titleFrom(body), {}, now);
|
|
797
|
+
const routed = routeUserSubmission(tx, createdAgg.task, "operator", body, effectiveIntent, now, submissionKey, { kind: "create" });
|
|
798
|
+
return { ...routed, task: createdAgg.task, created: true };
|
|
799
|
+
});
|
|
800
|
+
notifyMailbox(options.runtime, result.queuedForLeader ? leaderMailbox(result.task.id) : taskMailbox(result.task.id), result.task.id);
|
|
801
|
+
const header = result.created
|
|
802
|
+
? `Created Draft task ${result.task.id}: ${result.task.title}\n`
|
|
803
|
+
: "";
|
|
804
|
+
return `${header}${renderSubmissionFeedback(result.feedback)}`;
|
|
805
|
+
}
|
|
806
|
+
function findSubmissionKeyCreate(store, submissionKey) {
|
|
807
|
+
let otherTarget = false;
|
|
808
|
+
for (const task of store.listTasks()) {
|
|
809
|
+
for (const message of store.listMessages(task.id)) {
|
|
810
|
+
if (message.submissionKey !== submissionKey)
|
|
811
|
+
continue;
|
|
812
|
+
if (message.submissionReceipt?.target.kind === "create") {
|
|
813
|
+
return { kind: "create", taskId: task.id };
|
|
592
814
|
}
|
|
593
|
-
|
|
815
|
+
otherTarget = true;
|
|
594
816
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
enqueueWork(tx, leaderMailbox(created.task.id), "operator-input", now, [messageRef(created.task.id, message.id)]);
|
|
598
|
-
return { ...created, message, created: true };
|
|
599
|
-
});
|
|
600
|
-
notifyMailbox(options.runtime, leaderWakingTaskStatus(result.task.status) ? leaderMailbox(result.task.id) : taskMailbox(result.task.id), result.task.id);
|
|
601
|
-
return result.created
|
|
602
|
-
? `Created Draft task ${result.task.id}: ${result.task.title}\nSubmitted message ${result.message.id}\n`
|
|
603
|
-
: `Submitted message ${result.message.id} to ${result.task.id}\n`;
|
|
817
|
+
}
|
|
818
|
+
return otherTarget ? { kind: "other-target" } : { kind: "unused" };
|
|
604
819
|
}
|
|
605
820
|
function createTaskCommand(args, store, options) {
|
|
606
821
|
const parsed = parseTaskCreation(args, store);
|
|
@@ -713,6 +928,7 @@ function showTaskCommand(args, store, currentTaskCandidate) {
|
|
|
713
928
|
const integrations = store.listIntegrationAttempts(task.id);
|
|
714
929
|
const publications = store.listPublicationReferences(task.id);
|
|
715
930
|
const remoteDelivery = projectTaskRemoteDeliveryFromStore(store, task, currentTaskCandidate);
|
|
931
|
+
const archive = task.status === "archived" ? taskArchiveDiagnostics(store, task) : undefined;
|
|
716
932
|
const verifiedMergedPublications = publications.filter((reference) => (reference.state === "merged" && reference.verification === "verified")).length;
|
|
717
933
|
const currentMessageCount = operationalTaskRecords(messages, events, "message").length;
|
|
718
934
|
const currentWorkItemCount = work.filter(({ status }) => status !== "retired").length;
|
|
@@ -770,6 +986,7 @@ function showTaskCommand(args, store, currentTaskCandidate) {
|
|
|
770
986
|
`Integration Attempts: ${counts.integrations}`,
|
|
771
987
|
`Publication references: ${counts.publications} (${verifiedMergedPublications} verified merged)`,
|
|
772
988
|
renderTaskRemoteDelivery(remoteDelivery).trimEnd(),
|
|
989
|
+
...(archive === undefined ? [] : [renderArchiveDiagnostics(archive).trimEnd()]),
|
|
773
990
|
`Open inputs: ${counts.openInputs}`,
|
|
774
991
|
`Created: ${presentTime(task.createdAt, timeZone)}`,
|
|
775
992
|
`Updated: ${presentTime(task.updatedAt, timeZone)}`
|
|
@@ -778,7 +995,8 @@ function showTaskCommand(args, store, currentTaskCandidate) {
|
|
|
778
995
|
task,
|
|
779
996
|
counts,
|
|
780
997
|
hasBrief: brief !== null,
|
|
781
|
-
remoteDelivery
|
|
998
|
+
remoteDelivery,
|
|
999
|
+
...(archive === undefined ? {} : { archive })
|
|
782
1000
|
});
|
|
783
1001
|
}
|
|
784
1002
|
function activateTaskCommand(args, store, options) {
|
|
@@ -884,8 +1102,8 @@ function completeTaskCommand(args, store, options) {
|
|
|
884
1102
|
throw usageError(formatCompletionBlockers(task.id, readiness.blockers));
|
|
885
1103
|
}
|
|
886
1104
|
for (const ref of request.artifactRefs) {
|
|
887
|
-
if (ref
|
|
888
|
-
fixedArtifactRefs(
|
|
1105
|
+
if (isGitArtifactRefString(ref)) {
|
|
1106
|
+
fixedArtifactRefs(task.id, [ref]);
|
|
889
1107
|
}
|
|
890
1108
|
else if (ref.startsWith("turn:")) {
|
|
891
1109
|
const run = tx.getRun(task.id, ref.slice("turn:".length));
|
|
@@ -894,7 +1112,7 @@ function completeTaskCommand(args, store, options) {
|
|
|
894
1112
|
}
|
|
895
1113
|
}
|
|
896
1114
|
else if (!/^https?:\/\/[^\s]+$/u.test(ref)) {
|
|
897
|
-
throw usageError("Completion --artifact-ref must be a
|
|
1115
|
+
throw usageError("Completion --artifact-ref must be a commit-pinned git:<commit>:<relativePath> reference, turn:<local-turn-id>, or an explicit HTTP(S) reference URL.");
|
|
898
1116
|
}
|
|
899
1117
|
}
|
|
900
1118
|
const completed = completeTask(task, now, { by: actor, summary, artifactRefs: request.artifactRefs });
|
|
@@ -1043,50 +1261,59 @@ function archiveTaskCommand(args, store, options) {
|
|
|
1043
1261
|
&& task.status !== "cancelled") {
|
|
1044
1262
|
throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
|
|
1045
1263
|
}
|
|
1046
|
-
const remoteDelivery = request.
|
|
1047
|
-
?
|
|
1048
|
-
:
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
.
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
+
}
|
|
1083
1306
|
}
|
|
1084
|
-
const
|
|
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" } };
|
|
1085
1310
|
tx.saveTask(archived);
|
|
1086
|
-
tx.clearPendingWakeup(task.id);
|
|
1087
1311
|
tx.clearLeaderFailure(task.id);
|
|
1088
|
-
|
|
1089
|
-
tx.
|
|
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
|
+
}
|
|
1090
1317
|
}
|
|
1091
1318
|
const remoteProjectHeads = remoteDelivery === undefined
|
|
1092
1319
|
? undefined
|
|
@@ -1103,27 +1330,29 @@ function archiveTaskCommand(args, store, options) {
|
|
|
1103
1330
|
recordTaskEvent(tx, task.id, "task.archived", {
|
|
1104
1331
|
by: actor,
|
|
1105
1332
|
workspaceDisposition: request.disposition,
|
|
1333
|
+
...(request.force ? {
|
|
1334
|
+
force: "true", cleanup: "pending",
|
|
1335
|
+
warnings: JSON.stringify(warnings),
|
|
1336
|
+
retainedResources: JSON.stringify(retainedResources)
|
|
1337
|
+
} : {}),
|
|
1106
1338
|
...(remoteDelivery === undefined
|
|
1107
1339
|
? {}
|
|
1108
1340
|
: {
|
|
1109
1341
|
mergeCoverage: remoteDelivery.status,
|
|
1110
1342
|
allMerged: String(remoteDelivery.allMerged),
|
|
1111
|
-
allVerified: String(remoteDelivery.allVerified)
|
|
1112
|
-
...(request.forceUnverified && !remoteDelivery.allVerified
|
|
1113
|
-
? { verificationOverride: "true" }
|
|
1114
|
-
: {})
|
|
1343
|
+
allVerified: String(remoteDelivery.allVerified)
|
|
1115
1344
|
}),
|
|
1116
1345
|
...(remoteProjectHeads === undefined ? {} : { projectHeads: remoteProjectHeads }),
|
|
1117
1346
|
...(remoteProjectBases === undefined ? {} : { projectBases: remoteProjectBases })
|
|
1118
1347
|
}, now);
|
|
1119
|
-
enqueueWork(tx, taskMailbox(task.id), "task-archived", now, [taskRef(task.id)]);
|
|
1120
1348
|
return { task: archived, changed: true };
|
|
1121
1349
|
});
|
|
1122
|
-
if (result.changed)
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1125
1354
|
? `Archived task ${result.task.id}\n`
|
|
1126
|
-
: `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 });
|
|
1127
1356
|
}
|
|
1128
1357
|
function cancelTaskCommand(args, store, options) {
|
|
1129
1358
|
const usage = "Task cancel usage: yui task cancel <task> (--summary <text>|--summary-file <path|->).";
|
|
@@ -1189,6 +1418,7 @@ function retireTaskCommand(args, store, options) {
|
|
|
1189
1418
|
}
|
|
1190
1419
|
const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
|
|
1191
1420
|
|| integration.status === "blocked"
|
|
1421
|
+
|| integration.status === "conflicted"
|
|
1192
1422
|
|| integration.status === "validating"));
|
|
1193
1423
|
if (unresolvedIntegration !== undefined) {
|
|
1194
1424
|
throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
|
|
@@ -1284,7 +1514,7 @@ function assertTaskRetirementProof(store, task, proof) {
|
|
|
1284
1514
|
}
|
|
1285
1515
|
export function parseTaskArchiveArguments(args) {
|
|
1286
1516
|
const usage = "Task archive usage: "
|
|
1287
|
-
+ "yui task archive <id> (--integrated [--force]
|
|
1517
|
+
+ "yui task archive <id> (--integrated|--abandon) [--force].";
|
|
1288
1518
|
const taskId = args[0]?.trim();
|
|
1289
1519
|
const flags = args.slice(1);
|
|
1290
1520
|
if (taskId === undefined
|
|
@@ -1295,14 +1525,14 @@ export function parseTaskArchiveArguments(args) {
|
|
|
1295
1525
|
}
|
|
1296
1526
|
const integrated = flags.includes("--integrated");
|
|
1297
1527
|
const abandoned = flags.includes("--abandon");
|
|
1298
|
-
const
|
|
1299
|
-
if (integrated === abandoned
|
|
1528
|
+
const force = flags.includes("--force");
|
|
1529
|
+
if (integrated === abandoned) {
|
|
1300
1530
|
throw usageError(usage);
|
|
1301
1531
|
}
|
|
1302
1532
|
return {
|
|
1303
1533
|
taskId,
|
|
1304
1534
|
disposition: integrated ? "integrated" : "abandoned",
|
|
1305
|
-
|
|
1535
|
+
force
|
|
1306
1536
|
};
|
|
1307
1537
|
}
|
|
1308
1538
|
function formatProjectCommits(projects) {
|
|
@@ -1321,10 +1551,11 @@ export function validateTaskArchiveRequest(args, store, options = {}) {
|
|
|
1321
1551
|
&& task.status !== "cancelled") {
|
|
1322
1552
|
throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
|
|
1323
1553
|
}
|
|
1324
|
-
if (task.status !== "archived") {
|
|
1554
|
+
if (task.status !== "archived" && !request.force) {
|
|
1325
1555
|
assertNoOpenInputRequests(store, task.id, "archiving the Task");
|
|
1326
1556
|
const unresolvedIntegration = store.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
|
|
1327
1557
|
|| integration.status === "blocked"
|
|
1558
|
+
|| integration.status === "conflicted"
|
|
1328
1559
|
|| integration.status === "validating"));
|
|
1329
1560
|
if (unresolvedIntegration !== undefined) {
|
|
1330
1561
|
throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
|
|
@@ -1388,8 +1619,8 @@ function taskMessageCommand(args, store, options) {
|
|
|
1388
1619
|
return { kind: "output", output: `${JSON.stringify(expanded, null, 2)}\n`, data: expanded };
|
|
1389
1620
|
}
|
|
1390
1621
|
if (command === "send") {
|
|
1391
|
-
const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->) [--wake-policy leader|none] [--to <role> --work-item <id>|--review-round <id>].";
|
|
1392
|
-
const parsed = parseTail(rest, new Set(["--body-file", "--wake-policy", "--to", "--work-item", "--review-round"]), usage);
|
|
1622
|
+
const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->) [--intent record|discuss|develop] [--request-id <key>] [--wake-policy leader|none] [--to <role> --work-item <id>|--review-round <id>].";
|
|
1623
|
+
const parsed = parseTail(rest, new Set(["--body-file", "--intent", "--request-id", "--wake-policy", "--to", "--work-item", "--review-round"]), usage);
|
|
1393
1624
|
if (parsed.positionals.length < 1 || parsed.positionals.length > 2)
|
|
1394
1625
|
throw usageError(usage);
|
|
1395
1626
|
const body = readCommandText(parsed.positionals[1], parsed.options.get("--body-file"), "--body", usage);
|
|
@@ -1404,18 +1635,32 @@ function taskMessageCommand(args, store, options) {
|
|
|
1404
1635
|
else {
|
|
1405
1636
|
throw usageError(`--wake-policy must be 'leader' or 'none': ${wakePolicyRaw}.`);
|
|
1406
1637
|
}
|
|
1638
|
+
const intent = parseSubmissionIntentOption(parsed.options.get("--intent"), usage);
|
|
1639
|
+
const submissionKey = parsed.options.get("--request-id");
|
|
1640
|
+
if (submissionKey !== undefined && submissionKey.trim().length === 0) {
|
|
1641
|
+
throw usageError("--request-id is required.", usage);
|
|
1642
|
+
}
|
|
1407
1643
|
const recipientRole = parsed.options.get("--to");
|
|
1408
1644
|
const workItemId = parsed.options.get("--work-item");
|
|
1409
1645
|
const reviewRoundId = parsed.options.get("--review-round");
|
|
1410
1646
|
if (recipientRole === undefined && (workItemId !== undefined || reviewRoundId !== undefined))
|
|
1411
1647
|
throw usageError("--to is required for scoped Message delivery.");
|
|
1412
|
-
const result = sendTaskMessageCommand(store, parsed.positionals[0], body, wakePolicy, options, recipientRole === undefined ? undefined : { roleName: recipientRole, workItemId, reviewRoundId });
|
|
1648
|
+
const result = sendTaskMessageCommand(store, parsed.positionals[0], body, wakePolicy, options, recipientRole === undefined ? undefined : { roleName: recipientRole, workItemId, reviewRoundId }, intent, submissionKey);
|
|
1649
|
+
// A user/operator submission returns the unified §2.5 feedback; render each
|
|
1650
|
+
// facet on its own line and expose the structure to non-text callers.
|
|
1651
|
+
if (result.feedback !== undefined) {
|
|
1652
|
+
return output(renderSubmissionFeedback(result.feedback), { taskId: result.task.id, message: result.message, submission: result.feedback });
|
|
1653
|
+
}
|
|
1413
1654
|
const reason = result.message.continuation?.notDeliveredReason;
|
|
1414
1655
|
const delivery = reason !== undefined ? { state: "not-delivered", reason }
|
|
1415
|
-
: recipientRole !== undefined || result.
|
|
1656
|
+
: recipientRole !== undefined || result.queuedForLeader
|
|
1416
1657
|
? { state: "queued" } : { state: "saved" };
|
|
1417
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 });
|
|
1418
1659
|
}
|
|
1660
|
+
if (command === "queue")
|
|
1661
|
+
return queueTaskMessage(rest, store, options);
|
|
1662
|
+
if (command === "steer")
|
|
1663
|
+
return steerTaskMessage(rest, store, options);
|
|
1419
1664
|
if (command === "list") {
|
|
1420
1665
|
const messageListUsage = "Task message list usage: yui task message list <id> [--after <timestamp>] [--limit <n>].";
|
|
1421
1666
|
const parsed = parseTail(rest, new Set(["--after", "--limit"]), messageListUsage);
|
|
@@ -1469,6 +1714,149 @@ function taskMessageCommand(args, store, options) {
|
|
|
1469
1714
|
? "Task message command is required."
|
|
1470
1715
|
: `Unknown command: task message ${command}`);
|
|
1471
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
|
+
}
|
|
1472
1860
|
/** CLI and authenticated user Surface share the same message and mailbox
|
|
1473
1861
|
* transaction. Talking to Leader does not impersonate Leader authority. */
|
|
1474
1862
|
/**
|
|
@@ -1487,15 +1875,47 @@ function taskMessageCommand(args, store, options) {
|
|
|
1487
1875
|
* execution is decided by the Controller from the Task's own status, so this
|
|
1488
1876
|
* function never names a purpose and no second planning path exists.
|
|
1489
1877
|
*/
|
|
1490
|
-
export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options = {}, recipient) {
|
|
1878
|
+
export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options = {}, recipient, intent, submissionKey, inputControl) {
|
|
1491
1879
|
if (!body.trim())
|
|
1492
1880
|
throw usageError("Message body is required.");
|
|
1493
1881
|
if (recipient !== undefined && wakePolicy !== undefined) {
|
|
1494
1882
|
throw usageError("--wake-policy applies only to unaddressed Leader Messages; an owner-directed Message uses its exact continuation boundary.");
|
|
1495
1883
|
}
|
|
1884
|
+
if (recipient !== undefined && intent !== undefined) {
|
|
1885
|
+
throw usageError("A submission intent applies only to unaddressed Leader Messages; an owner-directed Message uses its exact continuation boundary.");
|
|
1886
|
+
}
|
|
1887
|
+
if (recipient !== undefined && submissionKey !== undefined) {
|
|
1888
|
+
throw usageError("A submission key applies only to unaddressed Leader Messages; an owner-directed Message uses its exact continuation boundary.");
|
|
1889
|
+
}
|
|
1496
1890
|
const now = clock(options);
|
|
1497
1891
|
const result = store.transaction((tx) => {
|
|
1498
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
|
+
}
|
|
1499
1919
|
if (recipient === undefined)
|
|
1500
1920
|
assertTaskOpen(task);
|
|
1501
1921
|
const caller = currentManagedRuntime(tx, options.environment, task.id);
|
|
@@ -1508,6 +1928,23 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
|
|
|
1508
1928
|
}
|
|
1509
1929
|
}
|
|
1510
1930
|
const actor = roleCaller === undefined ? taskActor(tx, options, task.id) : "role";
|
|
1931
|
+
// A user/operator Message with no explicit recipient is the single path that
|
|
1932
|
+
// carries submission intent (record | discuss | develop). It routes through
|
|
1933
|
+
// the one shared submission service so intent means exactly the same thing
|
|
1934
|
+
// here as on every other surface. Internal role/leader result messages and
|
|
1935
|
+
// owner-directed continuations keep their exact existing boundary and never
|
|
1936
|
+
// gain develop authority (task-32 §2.5).
|
|
1937
|
+
if (recipient === undefined && (actor === "user" || actor === "operator")) {
|
|
1938
|
+
const effectiveIntent = normalizeSubmissionIntent(intent, wakePolicy);
|
|
1939
|
+
const routed = routeUserSubmission(tx, task, actor, body, effectiveIntent, now, submissionKey, { kind: "task", taskId: task.id }, inputControl);
|
|
1940
|
+
return {
|
|
1941
|
+
task: routed.task, message: routed.message, actor,
|
|
1942
|
+
queuedForLeader: routed.queuedForLeader, feedback: routed.feedback, idempotentReplay: false
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
if (intent !== undefined) {
|
|
1946
|
+
throw usageError("Submission intent applies only to an unaddressed user or Operator Message.");
|
|
1947
|
+
}
|
|
1511
1948
|
if (recipient !== undefined && actor === "leader")
|
|
1512
1949
|
assertTaskDeliveryAuthority(tx, options.environment, task.id);
|
|
1513
1950
|
const target = recipient === undefined ? undefined
|
|
@@ -1519,9 +1956,9 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
|
|
|
1519
1956
|
...(recipient.reviewRoundId === undefined ? {} : { reviewRoundId: recipient.reviewRoundId })
|
|
1520
1957
|
});
|
|
1521
1958
|
const context = {
|
|
1522
|
-
...(wakePolicy === undefined || actor === "leader" || actor === "role" ? {} : { wakePolicy }),
|
|
1523
1959
|
...(target === undefined ? {} : { recipient: target }),
|
|
1524
|
-
...(recipient?.workItemId === undefined ? {} : { workItemId: recipient.workItemId })
|
|
1960
|
+
...(recipient?.workItemId === undefined ? {} : { workItemId: recipient.workItemId }),
|
|
1961
|
+
...(inputControl === undefined ? {} : { inputControl })
|
|
1525
1962
|
};
|
|
1526
1963
|
const message = actor === "leader"
|
|
1527
1964
|
? appendMessage(tx, task.id, body, "role-result", { type: "role", roleName: LEADER_ROLE }, now, context)
|
|
@@ -1530,8 +1967,13 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
|
|
|
1530
1967
|
: actor === "operator"
|
|
1531
1968
|
? appendMessage(tx, task.id, body, "operator", { type: "operator" }, now, context)
|
|
1532
1969
|
: appendMessage(tx, task.id, body, "user", { type: "user" }, now, context);
|
|
1970
|
+
// Reached only by addressed Messages and by an unaddressed Leader result now:
|
|
1971
|
+
// unaddressed user/operator submissions are handled above by the shared
|
|
1972
|
+
// service. A Worker/Reviewer message addressed to its Leader still wakes the
|
|
1973
|
+
// Leader exactly as before; an owner-directed continuation posts to the Task
|
|
1974
|
+
// mailbox for its exact owner Run.
|
|
1533
1975
|
const queuedForLeader = target?.ownerRunId === undefined
|
|
1534
|
-
&& leaderWakingTaskStatus(task.status) && actor !== "leader"
|
|
1976
|
+
&& leaderWakingTaskStatus(task.status) && actor !== "leader";
|
|
1535
1977
|
if (target?.ownerRunId !== undefined) {
|
|
1536
1978
|
const reason = messageContinuationBlocker(tx, message);
|
|
1537
1979
|
if (reason !== undefined) {
|
|
@@ -1543,8 +1985,10 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
|
|
|
1543
1985
|
else if (queuedForLeader) {
|
|
1544
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}` });
|
|
1545
1987
|
}
|
|
1546
|
-
return { task, message, actor, queuedForLeader };
|
|
1988
|
+
return { task, message, actor, queuedForLeader, feedback: undefined, idempotentReplay: false };
|
|
1547
1989
|
});
|
|
1990
|
+
if (result.idempotentReplay)
|
|
1991
|
+
return result;
|
|
1548
1992
|
if (recipient !== undefined) {
|
|
1549
1993
|
notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
|
|
1550
1994
|
}
|
|
@@ -1554,6 +1998,13 @@ export function sendTaskMessageCommand(store, taskId, body, wakePolicy, options
|
|
|
1554
1998
|
}
|
|
1555
1999
|
return result;
|
|
1556
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
|
+
}
|
|
1557
2008
|
/**
|
|
1558
2009
|
* Whether an inbound Message on a Task in this status is delivered to its
|
|
1559
2010
|
* Leader. Draft qualifies because planning is a Leader conversation that
|
|
@@ -1708,6 +2159,8 @@ function taskRoleCommand(args, store, options) {
|
|
|
1708
2159
|
return output(unbindTaskRole(rest, store, options));
|
|
1709
2160
|
if (command === "session")
|
|
1710
2161
|
return taskRoleSessionCommand(rest, store, options);
|
|
2162
|
+
if (command === "interrupt")
|
|
2163
|
+
return interruptTaskRole(rest, store, options);
|
|
1711
2164
|
if (command === "view")
|
|
1712
2165
|
return viewTaskRole(rest, store);
|
|
1713
2166
|
if (command === "takeover")
|
|
@@ -1718,6 +2171,220 @@ function taskRoleCommand(args, store, options) {
|
|
|
1718
2171
|
? "Task role command is required."
|
|
1719
2172
|
: `Unknown command: task role ${command}`);
|
|
1720
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
|
+
}
|
|
1721
2388
|
function taskRoleSessionCommand(args, store, options) {
|
|
1722
2389
|
const [command, ...rest] = args;
|
|
1723
2390
|
if (command === "inspect") {
|
|
@@ -1739,6 +2406,7 @@ function taskRoleSessionCommand(args, store, options) {
|
|
|
1739
2406
|
// means there was nothing an Agent reported, and the one-line label above
|
|
1740
2407
|
// still states which of the "no value" cases applies.
|
|
1741
2408
|
const runConfiguration = active === null ? undefined : options.liveRunConfiguration;
|
|
2409
|
+
const host = options.liveHostObservations?.[role.name];
|
|
1742
2410
|
const runConfigurationDetail = renderAgentRunConfiguration(runConfiguration);
|
|
1743
2411
|
return output(active === null
|
|
1744
2412
|
? `No attached Session exists for ${task.id}/${role.name}.\n`
|
|
@@ -1752,6 +2420,7 @@ function taskRoleSessionCommand(args, store, options) {
|
|
|
1752
2420
|
`Native id: ${active.nativeSessionId}`,
|
|
1753
2421
|
`Session: ${active.status}${active.endReason === undefined ? "" : `/${active.endReason}`}`,
|
|
1754
2422
|
`AgentRun: ${binding?.run?.status ?? "none"}`,
|
|
2423
|
+
...(host === undefined ? [] : [`Host reporting: ${taskRoleHostDiagnostic(host)}`]),
|
|
1755
2424
|
`Run configuration: ${agentRunConfigurationLabel(runConfiguration)}`
|
|
1756
2425
|
].join("\n") + "\n"
|
|
1757
2426
|
+ `\n${renderRoleLaunchComparison(role, active.effective)}\n`
|
|
@@ -1760,6 +2429,7 @@ function taskRoleSessionCommand(args, store, options) {
|
|
|
1760
2429
|
role,
|
|
1761
2430
|
session: active,
|
|
1762
2431
|
providerBinding: binding,
|
|
2432
|
+
...(host === undefined ? {} : { host }),
|
|
1763
2433
|
...(runConfiguration === undefined ? {} : { runConfiguration })
|
|
1764
2434
|
});
|
|
1765
2435
|
}
|
|
@@ -1803,9 +2473,6 @@ function taskRoleSessionCommand(args, store, options) {
|
|
|
1803
2473
|
const now = clock(options);
|
|
1804
2474
|
const request = store.transaction((tx) => {
|
|
1805
2475
|
const task = requireTask(tx, parsed.positionals[0]);
|
|
1806
|
-
if (!["draft", "active", "completed", "cancelled"].includes(task.status)) {
|
|
1807
|
-
throw usageError(`Task Role Session stop is unavailable for an archived Task: ${task.id}.`, usage);
|
|
1808
|
-
}
|
|
1809
2476
|
const actor = taskActor(tx, options, task.id);
|
|
1810
2477
|
const role = requireRole(tx, task.id, parsed.positionals[1]);
|
|
1811
2478
|
if (actor === "leader" && role.name === LEADER_ROLE) {
|
|
@@ -1924,14 +2591,14 @@ function addTaskRole(args, store, options) {
|
|
|
1924
2591
|
`Runtime source: ${runtimeSource}`,
|
|
1925
2592
|
`Agent: ${result.role.activeAgentId}/${result.binding.adapterId}`,
|
|
1926
2593
|
`Model: ${result.binding.config.model ?? "CLI default"}; effort: ${result.binding.config.effort ?? "CLI default"}; permission: ${result.binding.config.permission.strategy}`,
|
|
1927
|
-
"Next:
|
|
2594
|
+
"Next: dispatch an assigned WorkItem, or request a Task-final Review with this Role; a Review needs no WorkItem."
|
|
1928
2595
|
].join("\n").concat("\n");
|
|
1929
2596
|
}
|
|
1930
2597
|
function listTaskRoles(args, store, options) {
|
|
1931
2598
|
exactPositionals(args, 1, "Task role list usage: yui task role list <task>.");
|
|
1932
2599
|
const task = requireTask(store, args[0]);
|
|
1933
2600
|
const roles = store.listRoles(task.id);
|
|
1934
|
-
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]));
|
|
1935
2602
|
if (statuses.length === 0)
|
|
1936
2603
|
return output("No roles assigned.\n", { roles: statuses });
|
|
1937
2604
|
return output(`${renderTable(`Task roles: ${task.id}`, [
|
|
@@ -1961,7 +2628,8 @@ function taskRoleStatus(args, store, options) {
|
|
|
1961
2628
|
const [status] = inspectTaskRoleRuntimeStatuses(task.id, [role], store, options.runtime?.inspectTaskRolePanes?.(task.id) ?? [], options.now?.() ?? new Date());
|
|
1962
2629
|
if (status === undefined)
|
|
1963
2630
|
throw roleNotFound(role.name);
|
|
1964
|
-
|
|
2631
|
+
const observed = withTaskRoleHostObservation(status, options.liveHostObservations?.[role.name]);
|
|
2632
|
+
return output(renderTaskRoleRuntimeStatus(observed), { role: observed });
|
|
1965
2633
|
}
|
|
1966
2634
|
function showTaskRole(args, store) {
|
|
1967
2635
|
exactPositionals(args, 2, "Task role show usage: yui task role show <task> <role>.");
|
|
@@ -2527,7 +3195,7 @@ function updateWorkScope(args, store, options) {
|
|
|
2527
3195
|
return `${updated.changed ? "Updated" : "Unchanged"} Work Item Project scope ${updated.item.id}: ${updated.item.writeProjectIds.join(", ") || "read-only"}\n`;
|
|
2528
3196
|
}
|
|
2529
3197
|
function updateWork(args, store, options) {
|
|
2530
|
-
const usage = "Task work update usage: yui task work update <task>/<work> <todo|running|done|failed> [--summary <text>] [--artifact-ref
|
|
3198
|
+
const usage = "Task work update usage: yui task work update <task>/<work> <todo|running|done|failed> [--summary <text>] [--artifact-ref git:<commit>:<relative-path> ...].";
|
|
2531
3199
|
const parsed = parseMultiValueTail(args, new Set(["--summary"]), new Set(["--artifact-ref"]), usage);
|
|
2532
3200
|
exactPositionals(parsed.positionals, 2, usage);
|
|
2533
3201
|
const requested = parsed.positionals[1];
|
|
@@ -2546,7 +3214,7 @@ function updateWork(args, store, options) {
|
|
|
2546
3214
|
const current = requireWorkItem(tx, parsed.positionals[0], options);
|
|
2547
3215
|
const task = requireTask(tx, current.taskId);
|
|
2548
3216
|
assertTaskOpen(task);
|
|
2549
|
-
const artifactRefs = artifactIds.length === 0 ? undefined : fixedArtifactRefs(
|
|
3217
|
+
const artifactRefs = artifactIds.length === 0 ? undefined : fixedArtifactRefs(task.id, artifactIds);
|
|
2550
3218
|
if (current.assignee === undefined) {
|
|
2551
3219
|
taskActor(tx, options, task.id);
|
|
2552
3220
|
if (status === "running") {
|
|
@@ -2708,6 +3376,17 @@ function updateWork(args, store, options) {
|
|
|
2708
3376
|
workItem: result.item
|
|
2709
3377
|
});
|
|
2710
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
|
+
}
|
|
2711
3390
|
function dispatchWork(args, store, options) {
|
|
2712
3391
|
const usage = "Task work dispatch usage: yui task work dispatch <task>/<work> [--input <text>] [--lane-role <role> ...].";
|
|
2713
3392
|
const parsed = parseMultiValueTail(args, new Set(["--input"]), new Set(["--lane-role"]), usage);
|
|
@@ -2721,11 +3400,8 @@ function dispatchWork(args, store, options) {
|
|
|
2721
3400
|
if (task.status !== "active")
|
|
2722
3401
|
throw usageError(inactiveTaskMessage(task, "dispatch"));
|
|
2723
3402
|
assertTaskExecutionEnabled(task, "dispatching work");
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
+ `The Task Leader must run "yui task work update ${item.id} running" and execute it directly.`);
|
|
2727
|
-
}
|
|
2728
|
-
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)}`);
|
|
2729
3405
|
const currentGroup = currentWorkItemExecutionGroup(item);
|
|
2730
3406
|
if (item.status !== "open") {
|
|
2731
3407
|
throw usageError(`Work item ${item.id} cannot be dispatched from ${item.status}.`);
|
|
@@ -2762,7 +3438,7 @@ function dispatchWork(args, store, options) {
|
|
|
2762
3438
|
const rawInput = trimmed(parsed.options.get("--input")) ?? item.objective;
|
|
2763
3439
|
let workItemForDispatch = prepareWorkItemDispatch(item, now);
|
|
2764
3440
|
if (lanePlan.roles.length === 0) {
|
|
2765
|
-
const role = requireRole(tx, task.id,
|
|
3441
|
+
const role = requireRole(tx, task.id, assignee);
|
|
2766
3442
|
if (tx.getActiveRun(task.id, role.name) !== null) {
|
|
2767
3443
|
throw usageError(`${task.id}/${role.name} already has an active run.`);
|
|
2768
3444
|
}
|
|
@@ -2954,8 +3630,11 @@ function acceptWork(args, store, options) {
|
|
|
2954
3630
|
: item.candidates.find(({ id }) => id === candidateId);
|
|
2955
3631
|
if (candidate === undefined)
|
|
2956
3632
|
throw usageError(`Work Item Candidate not found: ${candidateId}.`);
|
|
2957
|
-
|
|
2958
|
-
|
|
3633
|
+
// A Candidate's artifact references are commit-pinned (git:<commit>:<path>):
|
|
3634
|
+
// the commit self-certifies the frozen bytes, so they cannot drift and are
|
|
3635
|
+
// re-validated for shape whenever the Candidate is loaded. Their bytes are
|
|
3636
|
+
// resolved lazily on the async read/context path, never re-derived from a DB
|
|
3637
|
+
// mirror here.
|
|
2959
3638
|
const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
|
|
2960
3639
|
const latestReview = reviewRoundsByIdentity(tx.listReviewRounds(item.taskId)
|
|
2961
3640
|
.filter((round) => round.workItemId === item.id
|
|
@@ -5535,15 +6214,27 @@ function requireWorkItemCandidate(item) {
|
|
|
5535
6214
|
}
|
|
5536
6215
|
return candidate;
|
|
5537
6216
|
}
|
|
5538
|
-
|
|
5539
|
-
|
|
6217
|
+
/**
|
|
6218
|
+
* Parse `--artifact-ref git:<commit>:<relativePath>` selectors into canonical
|
|
6219
|
+
* commit-pinned references for the given Task. This is a PURE shape check with
|
|
6220
|
+
* no Git or DB I/O: the commit self-certifies the frozen bytes, so a valid
|
|
6221
|
+
* pinned reference is complete evidence. The bytes are proven to exist lazily
|
|
6222
|
+
* when they are resolved on the async read/context path.
|
|
6223
|
+
*/
|
|
6224
|
+
function fixedArtifactRefs(taskId, refs) {
|
|
6225
|
+
if (new Set(refs).size !== refs.length)
|
|
5540
6226
|
throw usageError("Artifact references must be unique.");
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
6227
|
+
return refs.map((ref) => {
|
|
6228
|
+
if (!isGitArtifactRefString(ref)) {
|
|
6229
|
+
throw usageError("Artifact reference must be a commit-pinned git:<commit>:<relativePath> reference.");
|
|
6230
|
+
}
|
|
6231
|
+
try {
|
|
6232
|
+
return parseGitArtifactRef(ref, taskId);
|
|
6233
|
+
}
|
|
6234
|
+
catch (error) {
|
|
6235
|
+
throw usageError(`Artifact reference is invalid: ${messageOf(error)}`);
|
|
6236
|
+
}
|
|
6237
|
+
});
|
|
5547
6238
|
}
|
|
5548
6239
|
/** ReviewRound ids are the durable Task-local creation order; wall time is not causal. */
|
|
5549
6240
|
function reviewRoundsByIdentity(rounds) {
|
|
@@ -5558,7 +6249,7 @@ function activeReviewRoundForCandidate(store, item, candidate) {
|
|
|
5558
6249
|
}
|
|
5559
6250
|
function createTaskRole(store, task, roleName, explicitAgentId, now, sourceGlobalRoleName) {
|
|
5560
6251
|
const workspace = task.status === "draft"
|
|
5561
|
-
?
|
|
6252
|
+
? planningRuntimeCwd(store.rootDirectory(), task.id)
|
|
5562
6253
|
: task.cwd ?? store.getConfig().defaultWorkspace ?? process.cwd();
|
|
5563
6254
|
if (explicitAgentId === undefined) {
|
|
5564
6255
|
const sourceRoleName = sourceGlobalRoleName
|
|
@@ -5588,7 +6279,7 @@ function requireAgentProfile(store, id) {
|
|
|
5588
6279
|
}
|
|
5589
6280
|
function createTaskRoleFromAgentBinding(store, task, roleName, binding, now) {
|
|
5590
6281
|
const workspace = task.status === "draft"
|
|
5591
|
-
?
|
|
6282
|
+
? planningRuntimeCwd(store.rootDirectory(), task.id)
|
|
5592
6283
|
: task.cwd ?? store.getConfig().defaultWorkspace ?? process.cwd();
|
|
5593
6284
|
return createRole(task.id, roleName, [binding], binding.agentId, workspace, now);
|
|
5594
6285
|
}
|