@zq-silk/yui 0.15.9 → 0.15.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/cli/commandCatalog.js +26 -6
- 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 +336 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +511 -83
- 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/taskContext.js +38 -2
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +3 -0
- 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/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- 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/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 +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +136 -4
- package/dist/storage/sqliteStore.js +40 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/web/assets/client/app.js +55 -0
- 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 +106 -0
- 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 +117 -1
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/testing/verification-levels.md +24 -0
- package/docs/testing/verification-levels.zh-CN.md +11 -0
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +151 -49
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +6 -1
- package/skills/yui-runtime/SKILL.md +27 -0
- package/skills/yui-runtime/references/publication.md +20 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { roleNotFound, usageError } from "../errors/cliError.js";
|
|
2
2
|
import { createRoleSessionSet, recordRoleAgentSession } from "../executor/agentExecutor.js";
|
|
3
3
|
import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
|
|
4
|
+
import { managedGlobalRoleWorkspace } from "../storage/homeLayout.js";
|
|
5
|
+
import { createGlobalRoleMessage, claimGlobalRoleMessageInterruptThen } from "../message/message.js";
|
|
6
|
+
import { resolveGlobalInputControl } from "../message/inputControlResolution.js";
|
|
7
|
+
import { readCommandText } from "./textInput.js";
|
|
4
8
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
5
9
|
import { activeRoleSummary, renderRoleDetails } from "../output/rolePresentation.js";
|
|
6
10
|
import { activeRoleAgentBinding, createGlobalRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateGlobalRole } from "../role/role.js";
|
|
@@ -10,6 +14,28 @@ import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./rol
|
|
|
10
14
|
import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
|
|
11
15
|
export function runGlobalRoleCommand(args, store, options = {}) {
|
|
12
16
|
const [command, ...rest] = args;
|
|
17
|
+
if (command === "message" || command === "interrupt") {
|
|
18
|
+
const env = options.env ?? process.env;
|
|
19
|
+
const target = command === "message" ? rest[1] : rest[0];
|
|
20
|
+
if (env.YUI_SESSION_SCOPE !== undefined && env.YUI_SESSION_SCOPE !== "global"
|
|
21
|
+
|| env.YUI_SESSION_SCOPE === "global" && env.YUI_ROLE !== "operator"
|
|
22
|
+
&& env.YUI_ROLE !== target) {
|
|
23
|
+
throw usageError("Global input mutation is outside the exact Session authority.");
|
|
24
|
+
}
|
|
25
|
+
if (env.YUI_SESSION_SCOPE === "global") {
|
|
26
|
+
const caller = store.getGlobalRoleSessionSet(env.YUI_ROLE ?? "");
|
|
27
|
+
const session = caller?.sessions[caller.activeAgentId];
|
|
28
|
+
if (session === undefined || session.status !== "active" || env.YUI_AGENT_ID !== session.agentId
|
|
29
|
+
|| env.YUI_ADAPTER_ID !== session.adapterId
|
|
30
|
+
|| (env.CODEX_THREAD_ID ?? env.YUI_NATIVE_SESSION_ID) !== session.nativeSessionId) {
|
|
31
|
+
throw usageError("Global input mutation requires the current native caller Session.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (env.YUI_ROLE !== undefined || env.YUI_AGENT_ID !== undefined
|
|
35
|
+
|| env.YUI_NATIVE_SESSION_ID !== undefined) {
|
|
36
|
+
throw usageError("Incomplete managed identity cannot acquire user authority.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
13
39
|
switch (command) {
|
|
14
40
|
case "add": return addRole(rest, store, options);
|
|
15
41
|
case "list": return listRoles(rest, store);
|
|
@@ -21,6 +47,8 @@ export function runGlobalRoleCommand(args, store, options = {}) {
|
|
|
21
47
|
case "unbind": return unbindRole(rest, store);
|
|
22
48
|
case "enter": return enterRole(rest, store);
|
|
23
49
|
case "session": return roleSession(rest, store, options);
|
|
50
|
+
case "message": return globalRoleMessage(rest, store, options);
|
|
51
|
+
case "interrupt": return globalRoleInterrupt(rest, store, options);
|
|
24
52
|
default:
|
|
25
53
|
throw usageError(command === undefined
|
|
26
54
|
? "Role command is required."
|
|
@@ -32,7 +60,9 @@ function roleContext(args, store, options) {
|
|
|
32
60
|
const name = roleName(rawName);
|
|
33
61
|
assertNoArguments(rest, "Session context usage: yui session context <role>");
|
|
34
62
|
const environment = options.env ?? process.env;
|
|
35
|
-
|
|
63
|
+
// Context reads expose durable input and runtime facts without delivering it.
|
|
64
|
+
const selfRead = environment.YUI_SESSION_SCOPE !== undefined;
|
|
65
|
+
if (selfRead) {
|
|
36
66
|
if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ROLE !== name) {
|
|
37
67
|
throw usageError("Managed GlobalRole context is outside the exact Session authority.");
|
|
38
68
|
}
|
|
@@ -45,6 +75,19 @@ function roleContext(args, store, options) {
|
|
|
45
75
|
const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
|
|
46
76
|
const sessions = store.getGlobalRoleSessionSet(name);
|
|
47
77
|
const session = sessions?.sessions[role.activeAgentId];
|
|
78
|
+
// Only the controlled Endpoint's accepted receipt consumes a queued Message.
|
|
79
|
+
const queued = store.listGlobalRoleMessages(name).filter((message) => message.delivery === undefined
|
|
80
|
+
&& message.notDelivered === undefined
|
|
81
|
+
&& (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
|
|
82
|
+
const pendingQueue = queued.map((message) => ({
|
|
83
|
+
id: message.id,
|
|
84
|
+
requestId: message.interruptThen?.requestId ?? message.inputControl?.requestId,
|
|
85
|
+
body: message.body,
|
|
86
|
+
...(message.control === undefined ? {} : { control: message.control }),
|
|
87
|
+
...(message.deliveryTarget === undefined ? {} : { deliveryTarget: message.deliveryTarget }),
|
|
88
|
+
...(message.interruptThen === undefined ? {} : { interruptThen: true }),
|
|
89
|
+
...(message.delivery === undefined ? {} : { deliveredAt: message.delivery.deliveredAt })
|
|
90
|
+
}));
|
|
48
91
|
const context = Object.freeze({
|
|
49
92
|
schemaVersion: 1,
|
|
50
93
|
protocol: "yui-managed-context/v1",
|
|
@@ -79,6 +122,11 @@ function roleContext(args, store, options) {
|
|
|
79
122
|
? ["route-request", "inspect-catalog", "answer-user-boundary"]
|
|
80
123
|
: ["perform-global-role-request"]
|
|
81
124
|
},
|
|
125
|
+
// Reading pending input is neither delivery nor implementation.
|
|
126
|
+
pendingMessages: pendingQueue,
|
|
127
|
+
messages: store.listGlobalRoleMessages(name),
|
|
128
|
+
nativeTurn: sessions?.providerBinding?.run ?? null,
|
|
129
|
+
interrupts: sessions?.interrupts ?? {},
|
|
82
130
|
sessionManifestPath: environment.YUI_SESSION_MANIFEST,
|
|
83
131
|
cliCommand: "yui"
|
|
84
132
|
});
|
|
@@ -92,6 +140,8 @@ function roleContext(args, store, options) {
|
|
|
92
140
|
`Effective revision: ${role.launchRevision}`,
|
|
93
141
|
`Skills: ${context.profile.skillIds.join(", ") || "none"}`,
|
|
94
142
|
`Authority: ${context.authority.view}; Task implementation: no`,
|
|
143
|
+
`Pending messages: ${pendingQueue.length === 0 ? "none"
|
|
144
|
+
: pendingQueue.map((message) => message.id).join(", ")}`,
|
|
95
145
|
""
|
|
96
146
|
].join("\n");
|
|
97
147
|
}
|
|
@@ -109,9 +159,16 @@ function addRole(args, store, options) {
|
|
|
109
159
|
const created = store.transaction((tx) => {
|
|
110
160
|
assertRoleRuntimeMutationAllowed(tx, { scope: "global", roleName: name }, "creation");
|
|
111
161
|
const agent = requireAgent(agentId, tx);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
// An explicit --workspace is a user-chosen cwd and keeps its own semantics
|
|
163
|
+
// (external roots stay external). When none is given, default to a
|
|
164
|
+
// Home-internal Global Role working directory rather than the external
|
|
165
|
+
// `defaultWorkspace` or the ambient process.cwd(), so Yui-auto-created
|
|
166
|
+
// scratch never lands outside the canonical Home.
|
|
167
|
+
const explicitWorkspace = trimmed(parsed.one("--workspace"));
|
|
168
|
+
const workspace = explicitWorkspace
|
|
169
|
+
?? (options.yuiHome !== undefined
|
|
170
|
+
? managedGlobalRoleWorkspace(options.yuiHome)
|
|
171
|
+
: tx.getConfig().defaultWorkspace ?? process.cwd());
|
|
115
172
|
const binding = patchRoleAgentBinding(createRoleAgentBinding(definition(agent)), parsed);
|
|
116
173
|
const profile = roleProfileFrom(parsed);
|
|
117
174
|
validateConfiguredRoleSkills(options.yuiHome, profile.skills ?? []);
|
|
@@ -404,6 +461,259 @@ function assertSessionProvenance(command, role, nativeSessionId, environment) {
|
|
|
404
461
|
throw usageError("Native session id does not match CODEX_THREAD_ID.");
|
|
405
462
|
}
|
|
406
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* The shared application-layer primitive for a durable Global Role input
|
|
466
|
+
* (decision-3 §7: CLI and Web/API share one entry, not per-surface fallbacks).
|
|
467
|
+
* It persists the Global-owned Message with an explicit owner (the Role name)
|
|
468
|
+
* and a stable requestId, idempotent exactly like a Task input: an exact repeat
|
|
469
|
+
* of the same requestId returns the original Message, and any different body,
|
|
470
|
+
* action, or expectedTarget under the same id is a conflicting reuse, never a
|
|
471
|
+
* silent second input (decision-3 §6). It never fabricates a Task or a native
|
|
472
|
+
* Turn; it only records intent.
|
|
473
|
+
*/
|
|
474
|
+
export function sendGlobalRoleMessageCommand(store, roleName, body, author, inputControl, now) {
|
|
475
|
+
if (!body.trim())
|
|
476
|
+
throw usageError("Message body is required.");
|
|
477
|
+
const existing = store.listGlobalRoleMessages(roleName).find((entry) => (entry.inputControl ?? entry.interruptThen?.reusedInput)?.requestId === inputControl.requestId);
|
|
478
|
+
if (existing !== undefined) {
|
|
479
|
+
const original = existing.inputControl ?? existing.interruptThen?.reusedInput;
|
|
480
|
+
if (original?.action !== inputControl.action
|
|
481
|
+
|| existing.body !== body
|
|
482
|
+
|| original.expectedTarget !== inputControl.expectedTarget) {
|
|
483
|
+
throw usageError(`Input requestId ${inputControl.requestId} was already used with different content or target; use a new requestId for a new input.`);
|
|
484
|
+
}
|
|
485
|
+
return { message: existing, idempotentReplay: true };
|
|
486
|
+
}
|
|
487
|
+
const kind = author.type;
|
|
488
|
+
let message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), roleName, body, kind, author, now, { inputControl });
|
|
489
|
+
const sessions = store.getGlobalRoleSessionSet(roleName);
|
|
490
|
+
const session = sessions?.sessions[sessions.activeAgentId];
|
|
491
|
+
if (session !== undefined)
|
|
492
|
+
message = { ...message,
|
|
493
|
+
deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId } };
|
|
494
|
+
store.saveGlobalRoleMessage(message);
|
|
495
|
+
return { message, idempotentReplay: false };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* `yui role message queue|steer <role> ...` — the durable Global input actions.
|
|
499
|
+
* `queue` persists and returns queued (delivered at the Role's next legal
|
|
500
|
+
* execution opportunity by the existing global-role-runtime mailbox, never a new
|
|
501
|
+
* private queue). `steer` persists then resolves the exact current native Turn
|
|
502
|
+
* from durable state; on a ready resolution it returns a structured live intent
|
|
503
|
+
* the CLI performs against the Agent Host with scope "global", and on any
|
|
504
|
+
* explicit failure it returns the saved Message plus the exact code and never
|
|
505
|
+
* falls back to interrupt or queue (decision-3 §1/§5).
|
|
506
|
+
*/
|
|
507
|
+
function globalRoleMessage(args, store, options) {
|
|
508
|
+
const [action, rawName, ...tail] = args;
|
|
509
|
+
if (action !== "queue" && action !== "steer") {
|
|
510
|
+
throw usageError("Role message usage: yui role message queue|steer <role> (<body>|--body-file <path|->) --request-id <id> [--expected-target <turn>].");
|
|
511
|
+
}
|
|
512
|
+
const usage = action === "queue"
|
|
513
|
+
? "Role message queue usage: yui role message queue <role> (<body>|--body-file <path|->) --request-id <id>."
|
|
514
|
+
: "Role message steer usage: yui role message steer <role> (<body>|--body-file <path|->) --request-id <id> --expected-target <turn>.";
|
|
515
|
+
const name = roleName(rawName);
|
|
516
|
+
// A leading positional before the flags is the inline body; the rest are flags.
|
|
517
|
+
const positionalBody = tail.length > 0 && !tail[0].startsWith("--") ? tail[0] : undefined;
|
|
518
|
+
const flags = positionalBody === undefined ? tail : tail.slice(1);
|
|
519
|
+
const parsed = parseOptions(flags, new Map([
|
|
520
|
+
["--body-file", false],
|
|
521
|
+
["--request-id", false],
|
|
522
|
+
...(action === "steer" ? [["--expected-target", false]] : [])
|
|
523
|
+
]));
|
|
524
|
+
const body = readCommandText(positionalBody, parsed.one("--body-file"), "--body", usage);
|
|
525
|
+
const requestId = required(parsed.one("--request-id"), "--request-id");
|
|
526
|
+
const expectedTarget = action === "steer"
|
|
527
|
+
? required(parsed.one("--expected-target"), "--expected-target")
|
|
528
|
+
: undefined;
|
|
529
|
+
const now = new Date();
|
|
530
|
+
// The durable Global input is authored as an operator input: a Global Role has
|
|
531
|
+
// no Task Leader, and the operator is the human authority over Global Roles.
|
|
532
|
+
const environment = options.env ?? process.env;
|
|
533
|
+
const author = { type: environment.YUI_SESSION_SCOPE === "global"
|
|
534
|
+
? environment.YUI_ROLE === "operator" ? "operator" : "agent" : "user" };
|
|
535
|
+
const persisted = store.transaction((tx) => {
|
|
536
|
+
requireRole(name, tx);
|
|
537
|
+
return sendGlobalRoleMessageCommand(tx, name, body, author, { action, requestId, ...(expectedTarget === undefined ? {} : { expectedTarget }) }, now);
|
|
538
|
+
});
|
|
539
|
+
if (action === "queue") {
|
|
540
|
+
const state = persisted.idempotentReplay ? "idempotent-replay" : "queued";
|
|
541
|
+
return jsonOrText(options, `Queued Global message ${persisted.message.id} to ${name} (${state}).\n`, { roleName: name, message: persisted.message, delivery: { state } });
|
|
542
|
+
}
|
|
543
|
+
if (persisted.idempotentReplay) {
|
|
544
|
+
return jsonOrText(options, `Steer Global message ${persisted.message.id} already recorded (idempotent-replay).\n`, { roleName: name, message: persisted.message, steer: { state: "idempotent-replay" } });
|
|
545
|
+
}
|
|
546
|
+
const resolution = resolveGlobalInputControl(store, name, "steer", expectedTarget);
|
|
547
|
+
if (resolution.outcome !== "ready") {
|
|
548
|
+
return jsonOrText(options, `Steer Global message ${persisted.message.id} saved but not delivered (${resolution.code}: ${resolution.detail}).\n`, { roleName: name, message: persisted.message,
|
|
549
|
+
steer: { state: "not-steered", code: resolution.code, detail: resolution.detail } });
|
|
550
|
+
}
|
|
551
|
+
store.updateGlobalRoleMessage({ ...persisted.message, control: {
|
|
552
|
+
requestId, receiptId: `steer:${name}/${persisted.message.id}`,
|
|
553
|
+
outcome: "pending", observedAt: now.toISOString()
|
|
554
|
+
} });
|
|
555
|
+
return {
|
|
556
|
+
kind: "input-steer",
|
|
557
|
+
roleName: name,
|
|
558
|
+
messageId: persisted.message.id,
|
|
559
|
+
target: resolution.target,
|
|
560
|
+
receiptId: `steer:${name}/${persisted.message.id}`,
|
|
561
|
+
text: body,
|
|
562
|
+
output: `Steering ${name} at Turn ${resolution.target.nativeTurnId ?? resolution.target.attemptId} with message ${persisted.message.id}.\n`
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* `yui role interrupt <role> --expected-target <turn> [--then-message <ref>]` —
|
|
567
|
+
* a live interrupt of a Global Role's exact current native Turn. A bare
|
|
568
|
+
* interrupt persists no Message (it is pure control); a `--then-message` names
|
|
569
|
+
* an already-persisted durable Global Message to deliver once after a proven
|
|
570
|
+
* terminal — the ordered composition of interrupt and an existing queue, not a
|
|
571
|
+
* fourth action and not an auto-fallback (decision-3 §4).
|
|
572
|
+
*/
|
|
573
|
+
function globalRoleInterrupt(args, store, options) {
|
|
574
|
+
const usage = "Role interrupt usage: yui role interrupt <role> --expected-target <turn> [--then-message <global-message-id>] [--request-id <id>].";
|
|
575
|
+
const [rawName, ...tail] = args;
|
|
576
|
+
const name = roleName(rawName);
|
|
577
|
+
const parsed = parseOptions(tail, new Map([
|
|
578
|
+
["--expected-target", false],
|
|
579
|
+
["--then-message", false],
|
|
580
|
+
["--request-id", false]
|
|
581
|
+
]));
|
|
582
|
+
const expectedTarget = required(parsed.one("--expected-target"), "--expected-target");
|
|
583
|
+
const thenMessageId = trimmed(parsed.one("--then-message"));
|
|
584
|
+
const requestId = trimmed(parsed.one("--request-id")) ?? `cancel:${encodeURIComponent(expectedTarget)}`;
|
|
585
|
+
const fingerprint = JSON.stringify({ expectedTarget, thenMessageId });
|
|
586
|
+
const prior = store.getGlobalRoleSessionSet(name)?.interrupts?.[requestId];
|
|
587
|
+
if (prior !== undefined) {
|
|
588
|
+
if (prior.fingerprint !== fingerprint)
|
|
589
|
+
throw usageError("Interrupt requestId already names another control.");
|
|
590
|
+
return jsonOrText(options, `Interrupt ${requestId} already recorded; no native control repeated.\n`, { roleName: name, interrupt: prior.receipt ?? { state: "interrupt-unknown" }, idempotentReplay: true });
|
|
591
|
+
}
|
|
592
|
+
const resolved = store.transaction((tx) => {
|
|
593
|
+
requireRole(name, tx);
|
|
594
|
+
const resolution = resolveGlobalInputControl(tx, name, "interrupt", expectedTarget);
|
|
595
|
+
if (resolution.outcome !== "ready")
|
|
596
|
+
return resolution;
|
|
597
|
+
const sessions = tx.getGlobalRoleSessionSet(name);
|
|
598
|
+
if (Object.values(sessions.interrupts ?? {}).some(entry => entry.attemptId === resolution.target.attemptId
|
|
599
|
+
&& entry.nativeSessionId === resolution.target.nativeSessionId
|
|
600
|
+
&& !(entry.receipt?.state === "interrupt-unavailable" && entry.receipt.outcome === "rejected"))) {
|
|
601
|
+
return { outcome: "then-conflict", code: "TARGET_CHANGED",
|
|
602
|
+
detail: "This exact native Turn already has a recorded cancel request; inspect it instead of repeating the control." };
|
|
603
|
+
}
|
|
604
|
+
// decision-3 §4: before the live cancel, register the continuation claim on
|
|
605
|
+
// the already-persisted durable Global Message in one short transaction, so
|
|
606
|
+
// the handoff relationship, its target Turn, and its stable requestId are
|
|
607
|
+
// durable independent of the cancel outcome. An explicit then-Message must be
|
|
608
|
+
// an already-persisted durable Global Message owned by this exact Role; it is
|
|
609
|
+
// never created here and never a Task record (decision-3 §4/§9).
|
|
610
|
+
if (thenMessageId !== undefined) {
|
|
611
|
+
const claim = registerGlobalInterruptThen(tx, name, thenMessageId, resolution.target.attemptId, resolution.target.nativeTurnId, requestId, resolution.target);
|
|
612
|
+
if (claim !== "claimed")
|
|
613
|
+
return { outcome: "then-conflict", ...claim };
|
|
614
|
+
}
|
|
615
|
+
tx.saveGlobalRoleSessionSet({ ...sessions, interrupts: { ...sessions.interrupts,
|
|
616
|
+
[requestId]: { fingerprint, attemptId: resolution.target.attemptId,
|
|
617
|
+
nativeSessionId: resolution.target.nativeSessionId,
|
|
618
|
+
receiptId: `interrupt:${name}/${requestId}` }
|
|
619
|
+
} });
|
|
620
|
+
return resolution;
|
|
621
|
+
});
|
|
622
|
+
if (resolved.outcome === "then-conflict") {
|
|
623
|
+
return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
|
|
624
|
+
}
|
|
625
|
+
if (resolved.outcome !== "ready") {
|
|
626
|
+
return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
kind: "input-interrupt",
|
|
630
|
+
roleName: name,
|
|
631
|
+
target: resolved.target,
|
|
632
|
+
receiptId: `interrupt:${name}/${requestId}`,
|
|
633
|
+
...(thenMessageId === undefined ? {} : { thenMessageId }),
|
|
634
|
+
output: `Interrupting ${name} at Turn ${resolved.target.nativeTurnId ?? resolved.target.attemptId}`
|
|
635
|
+
+ `${thenMessageId === undefined ? "" : `, then delivering ${thenMessageId} once after a proven terminal`}.\n`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Bind a saved durable Global Message as the single interrupt-then continuation
|
|
640
|
+
* of an exact interrupted native Turn (decision-3 §4). The Global twin of the
|
|
641
|
+
* Task `registerInterruptThen`: a Global Role has no AgentRun, so the
|
|
642
|
+
* target is proven by the exact native Turn's attemptId under the Role's own
|
|
643
|
+
* writer fence, never by a fabricated Task Run. Idempotent per interrupt
|
|
644
|
+
* requestId; only one continuation may claim a given target Turn; a Message that
|
|
645
|
+
* was already delivered or already claims another target is refused, never
|
|
646
|
+
* silently dropped or duplicated.
|
|
647
|
+
*/
|
|
648
|
+
function registerGlobalInterruptThen(store, roleName, thenMessageId, targetAttemptId, targetNativeTurnId, requestId, target) {
|
|
649
|
+
const message = store.listGlobalRoleMessages(roleName).find((entry) => entry.id === thenMessageId);
|
|
650
|
+
if (message === undefined) {
|
|
651
|
+
throw usageError(`Then-Message ${thenMessageId} is not a durable Global Message owned by ${roleName}.`);
|
|
652
|
+
}
|
|
653
|
+
if (message.notDelivered !== undefined) {
|
|
654
|
+
return { code: "TARGET_CHANGED", detail: `The saved input is already settled not-delivered (${message.notDelivered.reason}).` };
|
|
655
|
+
}
|
|
656
|
+
if (message.control !== undefined && message.control.outcome !== "rejected") {
|
|
657
|
+
return { code: "TARGET_CHANGED", detail: "The saved input is already accepted or its delivery remains unconfirmed." };
|
|
658
|
+
}
|
|
659
|
+
if (message.deliveryTarget !== undefined
|
|
660
|
+
&& (message.deliveryTarget.nativeSessionId !== target.nativeSessionId
|
|
661
|
+
|| message.deliveryTarget.agentId !== target.agentId)) {
|
|
662
|
+
return { code: "TARGET_CHANGED", detail: "The saved input belongs to another native Session." };
|
|
663
|
+
}
|
|
664
|
+
// A synthesized fallback id is an opaque idempotency key (compared only for
|
|
665
|
+
// equality, never parsed), so it must satisfy the same safe-identity rule as a
|
|
666
|
+
// caller-supplied requestId: no path separators. Use `:` as the field joiner —
|
|
667
|
+
// the receiptId carries the `/`-shaped human receipt, this key stays slash-free.
|
|
668
|
+
const claimRequestId = requestId ?? `interrupt:${roleName}:${encodeURIComponent(targetAttemptId)}`;
|
|
669
|
+
// Idempotent per interrupt requestId: an exact repeat of the same claim is a
|
|
670
|
+
// no-op that still authorizes the live cancel.
|
|
671
|
+
if (message.interruptThen !== undefined) {
|
|
672
|
+
const claim = message.interruptThen;
|
|
673
|
+
const prior = store.getGlobalRoleSessionSet(roleName)?.interrupts?.[claim.requestId];
|
|
674
|
+
const canReferenceClaim = claim.requestId === claimRequestId
|
|
675
|
+
|| (prior?.receipt?.state === "interrupt-unavailable" && prior.receipt.outcome === "rejected");
|
|
676
|
+
if (canReferenceClaim && message.delivery === undefined && message.notDelivered === undefined
|
|
677
|
+
&& claim.targetAttemptId === targetAttemptId
|
|
678
|
+
&& claim.targetAgentId === target.agentId && claim.targetNativeSessionId === target.nativeSessionId
|
|
679
|
+
&& claim.targetAuthorityEpoch === target.authority.epoch
|
|
680
|
+
&& claim.targetAuthorityHolderId === target.authority.holderId)
|
|
681
|
+
return "claimed";
|
|
682
|
+
return { code: "TARGET_CHANGED",
|
|
683
|
+
detail: `Message ${thenMessageId} already claims a continuation of Turn ${message.interruptThen.targetAttemptId}.` };
|
|
684
|
+
}
|
|
685
|
+
// A Message that already delivered is a settled fact, not a fresh input to
|
|
686
|
+
// reuse; reusing it would replay a consumed queue entry.
|
|
687
|
+
if (message.delivery !== undefined) {
|
|
688
|
+
return { code: "TARGET_CHANGED",
|
|
689
|
+
detail: `Message ${thenMessageId} was already delivered at ${message.delivery.deliveredAt}.` };
|
|
690
|
+
}
|
|
691
|
+
// Only one continuation may claim a given target Turn.
|
|
692
|
+
const existing = store.listGlobalRoleMessages(roleName).find((entry) => entry.interruptThen?.targetAttemptId === targetAttemptId && entry.id !== message.id);
|
|
693
|
+
if (existing !== undefined) {
|
|
694
|
+
return { code: "TARGET_CHANGED",
|
|
695
|
+
detail: `Turn ${targetAttemptId} is already the terminal target of Message ${existing.id}.` };
|
|
696
|
+
}
|
|
697
|
+
store.updateGlobalRoleMessage(claimGlobalRoleMessageInterruptThen(message, {
|
|
698
|
+
requestId: claimRequestId, targetAttemptId,
|
|
699
|
+
targetNativeSessionId: target.nativeSessionId, targetAgentId: target.agentId,
|
|
700
|
+
targetAuthorityEpoch: target.authority.epoch,
|
|
701
|
+
targetAuthorityHolderId: target.authority.holderId,
|
|
702
|
+
...(targetNativeTurnId === undefined ? {} : { targetNativeTurnId })
|
|
703
|
+
}));
|
|
704
|
+
return "claimed";
|
|
705
|
+
}
|
|
706
|
+
/** Emit a plain string or, under --json, a JSON envelope, matching the Global
|
|
707
|
+
* Role command surface's existing string/JSON split. */
|
|
708
|
+
function jsonOrText(options, text, data) {
|
|
709
|
+
const record = data;
|
|
710
|
+
const failure = record.steer?.code === undefined ? record.interrupt : record.steer;
|
|
711
|
+
if (failure?.code !== undefined)
|
|
712
|
+
options.onInputFailure?.({
|
|
713
|
+
code: failure.code, detail: failure.detail ?? failure.code, data
|
|
714
|
+
});
|
|
715
|
+
return options.jsonOutput === true ? JSON.stringify(data) : text;
|
|
716
|
+
}
|
|
407
717
|
function parseOptions(args, specs) {
|
|
408
718
|
const values = new Map();
|
|
409
719
|
const seen = new Set();
|
|
@@ -7,6 +7,7 @@ import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../r
|
|
|
7
7
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
8
8
|
import { acquireProjectMaintenanceLock } from "../repository/projectMaintenanceLock.js";
|
|
9
9
|
import { addProjectKnowledge, addKnowledgeProposal, assertProjectActive, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
|
|
10
|
+
import { managedWorkspacesRoot } from "../storage/homeLayout.js";
|
|
10
11
|
import { projectActor } from "./taskActor.js";
|
|
11
12
|
export async function runProjectCommand(args, store, options = {}) {
|
|
12
13
|
const [command, ...rest] = args;
|
|
@@ -235,7 +236,7 @@ async function cloneProject(args, store, options) {
|
|
|
235
236
|
if (dirname(destination) !== workspaceRoot) {
|
|
236
237
|
throw usageError("Project clone destination must be directly inside the configured workspace.");
|
|
237
238
|
}
|
|
238
|
-
assertOutsideManagedWorktrees(destination,
|
|
239
|
+
assertOutsideManagedWorktrees(destination, store.rootDirectory());
|
|
239
240
|
ownership = "external";
|
|
240
241
|
}
|
|
241
242
|
else {
|
|
@@ -461,10 +462,10 @@ async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, bran
|
|
|
461
462
|
async function addProject(args, store, options) {
|
|
462
463
|
const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
|
|
463
464
|
const parsed = parseAddArguments(args, usage);
|
|
464
|
-
assertOutsideManagedWorktrees(parsed.path, store.
|
|
465
|
+
assertOutsideManagedWorktrees(parsed.path, store.rootDirectory());
|
|
465
466
|
const git = options.git ?? new NodeGitWorkspace();
|
|
466
467
|
const head = await git.inspect(parsed.path, "HEAD");
|
|
467
|
-
assertOutsideManagedWorktrees(head.root, store.
|
|
468
|
+
assertOutsideManagedWorktrees(head.root, store.rootDirectory());
|
|
468
469
|
if (!await git.isClean(head.root)) {
|
|
469
470
|
throw usageError("Project checkout must be clean before it can be registered.");
|
|
470
471
|
}
|
|
@@ -1097,10 +1098,8 @@ function assertProjectAvailable(store, project, exceptId) {
|
|
|
1097
1098
|
}
|
|
1098
1099
|
}
|
|
1099
1100
|
}
|
|
1100
|
-
function assertOutsideManagedWorktrees(path,
|
|
1101
|
-
|
|
1102
|
-
return;
|
|
1103
|
-
const managedRoot = join(resolve(workspace), "worktree");
|
|
1101
|
+
function assertOutsideManagedWorktrees(path, home) {
|
|
1102
|
+
const managedRoot = managedWorkspacesRoot(home);
|
|
1104
1103
|
const candidate = resolve(path);
|
|
1105
1104
|
const fromManagedRoot = relative(managedRoot, candidate);
|
|
1106
1105
|
const toManagedRoot = relative(candidate, managedRoot);
|
|
@@ -373,6 +373,24 @@ export function createReleaseActivatePorts(overrides = {}) {
|
|
|
373
373
|
if (failed.length > 0) {
|
|
374
374
|
throw new Error(`Release preflight storage compatibility checks failed: ${failed.join(", ")}.`);
|
|
375
375
|
}
|
|
376
|
+
// Doctor proves the target's storage, not the pinned code in existing
|
|
377
|
+
// Hosts. Ask the target's upgrade boundary for its independent Host
|
|
378
|
+
// protocol proof as well; release activation itself still never migrates.
|
|
379
|
+
const hostCheck = spawnSync(process.execPath, [cli, "--json", "upgrade", "--update-preflight"], {
|
|
380
|
+
env: { ...process.env, YUI_HOME: home, NO_COLOR: "1" },
|
|
381
|
+
encoding: "utf8", timeout: 60_000
|
|
382
|
+
});
|
|
383
|
+
let preflight;
|
|
384
|
+
try {
|
|
385
|
+
preflight = JSON.parse(hostCheck.stdout).data;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
throw new Error(`Release Host preflight produced no JSON: ${hostCheck.stderr.trim()}`);
|
|
389
|
+
}
|
|
390
|
+
if (hostCheck.status !== 0 || preflight?.outcome !== "update-preflight"
|
|
391
|
+
|| preflight.status !== "already-current") {
|
|
392
|
+
throw new Error(`Release Host compatibility preflight failed: ${preflight?.message ?? preflight?.status ?? "unknown"}. ${preflight?.action ?? ""}`);
|
|
393
|
+
}
|
|
376
394
|
}),
|
|
377
395
|
killOwnedProcess: overrides.killOwnedProcess ?? ((owner) => {
|
|
378
396
|
if (readLinuxProcessStartIdentity(owner.pid) !== owner.processStartIdentity)
|
|
@@ -52,6 +52,31 @@ export function assertTaskDeliveryAuthority(store, environment, taskId) {
|
|
|
52
52
|
}
|
|
53
53
|
return actor;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Authority for a live steer/interrupt of an exact current native Turn
|
|
57
|
+
* (decision-3 §1/§9, message-5 gap B). The legal evidence is the native
|
|
58
|
+
* Turn/Session, not planning→delivery elevation and not an AgentRun's existence:
|
|
59
|
+
* the resolution proves a present Turn under the caller's own current Session, so
|
|
60
|
+
* a planning (Draft) Leader may legally control its own management/planning Turn.
|
|
61
|
+
*
|
|
62
|
+
* Delivery authority is required only when a managed Leader redirects INTO a
|
|
63
|
+
* Worker/Reviewer execution Assignment (targetRole other than the Leader itself),
|
|
64
|
+
* which is the same boundary the addressed-Message send path enforces — a
|
|
65
|
+
* planning Session must not start or redirect execution work. A user or operator
|
|
66
|
+
* caller, and a Leader controlling its own Turn, need only be the current
|
|
67
|
+
* Session; an incomplete managed identity is refused by {@link taskLocalActor}.
|
|
68
|
+
*/
|
|
69
|
+
export function assertTaskInputControlAuthority(store, environment, taskId, targetRole) {
|
|
70
|
+
const actor = taskLocalActor(store, environment, taskId);
|
|
71
|
+
if (actor !== "leader" || targetRole === LEADER_ROLE)
|
|
72
|
+
return actor;
|
|
73
|
+
const caller = currentManagedRuntime(store, environment, taskId, LEADER_ROLE);
|
|
74
|
+
if (caller?.executionAuthority !== "delivery") {
|
|
75
|
+
throw usageError("This native Session has planning authority, not delivery authority. "
|
|
76
|
+
+ "Redirecting a Worker or Reviewer Assignment requires a delivery Session.");
|
|
77
|
+
}
|
|
78
|
+
return actor;
|
|
79
|
+
}
|
|
55
80
|
export function projectActor(environment) {
|
|
56
81
|
const env = environment ?? {};
|
|
57
82
|
if (env.YUI_SESSION_SCOPE === "task")
|