@zq-silk/yui 0.15.9 → 0.15.12
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 +8 -4
- package/ARCHITECTURE.zh-CN.md +5 -2
- package/README.md +13 -5
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/agentRun/agentRun.js +3 -0
- package/dist/cli/commandCatalog.js +64 -16
- package/dist/cli/interactionPolicy.js +7 -3
- package/dist/cli/managedDiagnostics.js +1 -1
- 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 +381 -107
- package/dist/commands/executionAuditCommands.js +10 -0
- package/dist/commands/globalRoleCommands.js +339 -4
- package/dist/commands/projectCommands.js +50 -22
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +586 -96
- package/dist/commands/taskIntegrationCommands.js +19 -39
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskOverviewCommand.js +4 -3
- package/dist/commands/taskPublicationAdoptCommand.js +127 -0
- package/dist/commands/taskPublicationCommands.js +11 -2
- package/dist/commands/taskPublicationVerifyCommand.js +23 -39
- package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +3 -0
- package/dist/context/taskCatalog.js +187 -0
- package/dist/context/taskContext.js +55 -6
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +14 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
- package/dist/controller/globalInputDelivery.js +132 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/providerRetryAdmission.js +100 -0
- package/dist/controller/providerRetryDelivery.js +218 -0
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +117 -7
- 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 +329 -386
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/lifecycle/exactRunTerminalization.js +4 -1
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/globalProviderRetry.js +15 -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/executionAudit.js +19 -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 +430 -107
- package/dist/repository/projectMaintenanceLock.js +75 -18
- package/dist/repository/taskWorkspaceCoordinator.js +182 -101
- package/dist/repository/taskWorkspacePreparer.js +205 -72
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/repository/workspaceCleanupInspection.js +187 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentError.js +5 -3
- package/dist/runtime/agentHost.js +179 -82
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/builtinAgentErrorMappers.js +91 -0
- package/dist/runtime/codexAppServerRuntime.js +34 -3
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/providerControl.js +5 -1
- package/dist/runtime/providerRetry.js +198 -0
- package/dist/runtime/providerRuntimeIdentity.js +28 -2
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/sessionTokenMetrics.js +15 -5
- package/dist/runtime/structuredProviderHost.js +6 -2
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/taskUsageMetrics.js +275 -0
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/activeRoleRunDelivery.js +12 -0
- package/dist/scheduler/leaderWakeupProcessor.js +5 -0
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +38 -6
- package/dist/scheduler/taskObservabilityProjection.js +6 -44
- 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 +167 -4
- package/dist/storage/sqliteStore.js +57 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -0
- package/dist/storage/taskCatalog.js +123 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +129 -0
- package/dist/task/archivePreflight.js +124 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/publicationAdoption.js +56 -0
- package/dist/task/publicationReference.js +10 -0
- package/dist/task/remoteDelivery.js +31 -16
- package/dist/web/assets/client/app.js +147 -17
- package/dist/web/assets/client/components.js +56 -13
- package/dist/web/assets/client/i18n.js +78 -4
- package/dist/web/assets/client/taskSurface.js +108 -1
- package/dist/web/assets/client/view.js +39 -8
- package/dist/web/assets/shell.js +29 -0
- package/dist/web/assets/styles/layout.js +8 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +131 -4
- package/dist/web/webSnapshot.js +16 -6
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/cleanupInspection.js +63 -0
- package/dist/workspace/workItemChangeSetManager.js +111 -35
- package/docs/agent-result-consumption.md +4 -0
- package/docs/agent-result-consumption.zh-CN.md +3 -0
- package/docs/agent-runtime-drivers.md +7 -0
- package/docs/agent-runtime-drivers.zh-CN.md +5 -0
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/README.zh-CN.md +3 -1
- package/docs/architecture/capabilities-and-resources.md +30 -5
- package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
- package/docs/managed-turn-and-session-runtime.md +47 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
- package/docs/observability/README.md +62 -0
- package/docs/observability/README.zh-CN.md +47 -0
- package/docs/project-refresh.md +77 -0
- package/docs/project-refresh.zh-CN.md +59 -0
- package/docs/provider-retry.md +70 -0
- 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/task-delivery.md +133 -13
- package/docs/task-delivery.zh-CN.md +99 -10
- package/docs/task-discovery.md +102 -0
- package/docs/task-discovery.zh-CN.md +86 -0
- package/docs/testing/verification-levels.md +40 -0
- package/docs/testing/verification-levels.zh-CN.md +23 -0
- package/i18n/README.zh-CN.md +13 -7
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +154 -51
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +19 -3
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-runtime/SKILL.md +42 -0
- package/skills/yui-runtime/references/publication.md +42 -0
- package/skills/yui-runtime/references/recovery.md +24 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { usageError } from "../errors/cliError.js";
|
|
9
9
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
10
|
+
import { formatUsageMetric } from "../runtime/taskUsageMetrics.js";
|
|
10
11
|
import { runExecutionAudit } from "../observability/executionAudit.js";
|
|
11
12
|
export function parseExecutionAuditOptions(args) {
|
|
12
13
|
const options = {};
|
|
@@ -246,6 +247,15 @@ export function renderExecutionAudit(report, width = defaultTableWidth()) {
|
|
|
246
247
|
else {
|
|
247
248
|
lines.push("", ...sectionError("orchestration", report));
|
|
248
249
|
}
|
|
250
|
+
if (report.usage.status === "ok" && report.usage.data !== undefined) {
|
|
251
|
+
lines.push("", "Observed usage — Task lifetime, observed sources only (audit time window not applied):");
|
|
252
|
+
for (const usage of report.usage.data) {
|
|
253
|
+
lines.push(` ${usage.taskId}: tokens=${formatUsageMetric(usage.tokens)}; tools=${formatUsageMetric(usage.toolCalls)}; elapsed=${formatUsageMetric(usage.elapsedSeconds, "s")}; native execution sum=${formatUsageMetric(usage.executionSeconds, "s")}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
lines.push("", ...sectionError("usage", report));
|
|
258
|
+
}
|
|
249
259
|
if (report.storage.status === "ok" && report.storage.data !== undefined) {
|
|
250
260
|
const storage = report.storage.data;
|
|
251
261
|
lines.push("", `Storage: backend ${storage.backend} · state.json ${formatBytes(storage.stateJsonBytes)} · yui.db ${formatBytes(storage.databaseBytes)}`
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { roleNotFound, usageError } from "../errors/cliError.js";
|
|
2
|
+
import { controlProviderRetry, providerRetryProjection, renderProviderRetry } from "../runtime/providerRetry.js";
|
|
3
|
+
import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
|
|
2
4
|
import { createRoleSessionSet, recordRoleAgentSession } from "../executor/agentExecutor.js";
|
|
3
5
|
import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
|
|
6
|
+
import { managedGlobalRoleWorkspace } from "../storage/homeLayout.js";
|
|
7
|
+
import { createGlobalRoleMessage, claimGlobalRoleMessageInterruptThen } from "../message/message.js";
|
|
8
|
+
import { resolveGlobalInputControl } from "../message/inputControlResolution.js";
|
|
9
|
+
import { readCommandText } from "./textInput.js";
|
|
4
10
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
5
11
|
import { activeRoleSummary, renderRoleDetails } from "../output/rolePresentation.js";
|
|
6
12
|
import { activeRoleAgentBinding, createGlobalRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateGlobalRole } from "../role/role.js";
|
|
@@ -10,6 +16,28 @@ import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./rol
|
|
|
10
16
|
import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
|
|
11
17
|
export function runGlobalRoleCommand(args, store, options = {}) {
|
|
12
18
|
const [command, ...rest] = args;
|
|
19
|
+
if (command === "message" || command === "interrupt" || command === "session" && rest[0] === "retry") {
|
|
20
|
+
const env = options.env ?? process.env;
|
|
21
|
+
const target = command === "message" || command === "session" ? rest[1] : rest[0];
|
|
22
|
+
if (env.YUI_SESSION_SCOPE !== undefined && env.YUI_SESSION_SCOPE !== "global"
|
|
23
|
+
|| env.YUI_SESSION_SCOPE === "global" && env.YUI_ROLE !== "operator"
|
|
24
|
+
&& env.YUI_ROLE !== target) {
|
|
25
|
+
throw usageError("Global input mutation is outside the exact Session authority.");
|
|
26
|
+
}
|
|
27
|
+
if (env.YUI_SESSION_SCOPE === "global") {
|
|
28
|
+
const caller = store.getGlobalRoleSessionSet(env.YUI_ROLE ?? "");
|
|
29
|
+
const session = caller?.sessions[caller.activeAgentId];
|
|
30
|
+
if (session === undefined || session.status !== "active" || env.YUI_AGENT_ID !== session.agentId
|
|
31
|
+
|| env.YUI_ADAPTER_ID !== session.adapterId
|
|
32
|
+
|| (env.CODEX_THREAD_ID ?? env.YUI_NATIVE_SESSION_ID) !== session.nativeSessionId) {
|
|
33
|
+
throw usageError("Global input mutation requires the current native caller Session.");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (env.YUI_ROLE !== undefined || env.YUI_AGENT_ID !== undefined
|
|
37
|
+
|| env.YUI_NATIVE_SESSION_ID !== undefined) {
|
|
38
|
+
throw usageError("Incomplete managed identity cannot acquire user authority.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
13
41
|
switch (command) {
|
|
14
42
|
case "add": return addRole(rest, store, options);
|
|
15
43
|
case "list": return listRoles(rest, store);
|
|
@@ -21,6 +49,8 @@ export function runGlobalRoleCommand(args, store, options = {}) {
|
|
|
21
49
|
case "unbind": return unbindRole(rest, store);
|
|
22
50
|
case "enter": return enterRole(rest, store);
|
|
23
51
|
case "session": return roleSession(rest, store, options);
|
|
52
|
+
case "message": return globalRoleMessage(rest, store, options);
|
|
53
|
+
case "interrupt": return globalRoleInterrupt(rest, store, options);
|
|
24
54
|
default:
|
|
25
55
|
throw usageError(command === undefined
|
|
26
56
|
? "Role command is required."
|
|
@@ -32,7 +62,9 @@ function roleContext(args, store, options) {
|
|
|
32
62
|
const name = roleName(rawName);
|
|
33
63
|
assertNoArguments(rest, "Session context usage: yui session context <role>");
|
|
34
64
|
const environment = options.env ?? process.env;
|
|
35
|
-
|
|
65
|
+
// Context reads expose durable input and runtime facts without delivering it.
|
|
66
|
+
const selfRead = environment.YUI_SESSION_SCOPE !== undefined;
|
|
67
|
+
if (selfRead) {
|
|
36
68
|
if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ROLE !== name) {
|
|
37
69
|
throw usageError("Managed GlobalRole context is outside the exact Session authority.");
|
|
38
70
|
}
|
|
@@ -45,6 +77,19 @@ function roleContext(args, store, options) {
|
|
|
45
77
|
const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
|
|
46
78
|
const sessions = store.getGlobalRoleSessionSet(name);
|
|
47
79
|
const session = sessions?.sessions[role.activeAgentId];
|
|
80
|
+
// Only the controlled Endpoint's accepted receipt consumes a queued Message.
|
|
81
|
+
const queued = store.listGlobalRoleMessages(name).filter((message) => message.delivery === undefined
|
|
82
|
+
&& message.notDelivered === undefined
|
|
83
|
+
&& (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
|
|
84
|
+
const pendingQueue = queued.map((message) => ({
|
|
85
|
+
id: message.id,
|
|
86
|
+
requestId: message.interruptThen?.requestId ?? message.inputControl?.requestId,
|
|
87
|
+
body: message.body,
|
|
88
|
+
...(message.control === undefined ? {} : { control: message.control }),
|
|
89
|
+
...(message.deliveryTarget === undefined ? {} : { deliveryTarget: message.deliveryTarget }),
|
|
90
|
+
...(message.interruptThen === undefined ? {} : { interruptThen: true }),
|
|
91
|
+
...(message.delivery === undefined ? {} : { deliveredAt: message.delivery.deliveredAt })
|
|
92
|
+
}));
|
|
48
93
|
const context = Object.freeze({
|
|
49
94
|
schemaVersion: 1,
|
|
50
95
|
protocol: "yui-managed-context/v1",
|
|
@@ -79,6 +124,12 @@ function roleContext(args, store, options) {
|
|
|
79
124
|
? ["route-request", "inspect-catalog", "answer-user-boundary"]
|
|
80
125
|
: ["perform-global-role-request"]
|
|
81
126
|
},
|
|
127
|
+
// Reading pending input is neither delivery nor implementation.
|
|
128
|
+
pendingMessages: pendingQueue,
|
|
129
|
+
messages: store.listGlobalRoleMessages(name),
|
|
130
|
+
nativeTurn: sessions?.providerBinding?.run ?? null,
|
|
131
|
+
retry: providerRetryProjection(sessions?.providerBinding),
|
|
132
|
+
interrupts: sessions?.interrupts ?? {},
|
|
82
133
|
sessionManifestPath: environment.YUI_SESSION_MANIFEST,
|
|
83
134
|
cliCommand: "yui"
|
|
84
135
|
});
|
|
@@ -92,6 +143,8 @@ function roleContext(args, store, options) {
|
|
|
92
143
|
`Effective revision: ${role.launchRevision}`,
|
|
93
144
|
`Skills: ${context.profile.skillIds.join(", ") || "none"}`,
|
|
94
145
|
`Authority: ${context.authority.view}; Task implementation: no`,
|
|
146
|
+
`Pending messages: ${pendingQueue.length === 0 ? "none"
|
|
147
|
+
: pendingQueue.map((message) => message.id).join(", ")}`,
|
|
95
148
|
""
|
|
96
149
|
].join("\n");
|
|
97
150
|
}
|
|
@@ -109,9 +162,16 @@ function addRole(args, store, options) {
|
|
|
109
162
|
const created = store.transaction((tx) => {
|
|
110
163
|
assertRoleRuntimeMutationAllowed(tx, { scope: "global", roleName: name }, "creation");
|
|
111
164
|
const agent = requireAgent(agentId, tx);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
165
|
+
// An explicit --workspace is a user-chosen cwd and keeps its own semantics
|
|
166
|
+
// (external roots stay external). When none is given, default to a
|
|
167
|
+
// Home-internal Global Role working directory rather than the external
|
|
168
|
+
// `defaultWorkspace` or the ambient process.cwd(), so Yui-auto-created
|
|
169
|
+
// scratch never lands outside the canonical Home.
|
|
170
|
+
const explicitWorkspace = trimmed(parsed.one("--workspace"));
|
|
171
|
+
const workspace = explicitWorkspace
|
|
172
|
+
?? (options.yuiHome !== undefined
|
|
173
|
+
? managedGlobalRoleWorkspace(options.yuiHome)
|
|
174
|
+
: tx.getConfig().defaultWorkspace ?? process.cwd());
|
|
115
175
|
const binding = patchRoleAgentBinding(createRoleAgentBinding(definition(agent)), parsed);
|
|
116
176
|
const profile = roleProfileFrom(parsed);
|
|
117
177
|
validateConfiguredRoleSkills(options.yuiHome, profile.skills ?? []);
|
|
@@ -325,6 +385,28 @@ function enterRole(args, store) {
|
|
|
325
385
|
}
|
|
326
386
|
function roleSession(args, store, options) {
|
|
327
387
|
const [command, rawName, ...tail] = args;
|
|
388
|
+
if (command === "retry") {
|
|
389
|
+
const name = roleName(rawName);
|
|
390
|
+
const [action = "show", ...extra] = tail;
|
|
391
|
+
if (extra.length > 0 || !["show", "cancel", "disable", "enable"].includes(action)) {
|
|
392
|
+
throw usageError("Usage: yui session retry <role> [show|cancel|disable|enable]");
|
|
393
|
+
}
|
|
394
|
+
return store.transaction(tx => {
|
|
395
|
+
requireRole(name, tx);
|
|
396
|
+
const sessions = tx.getGlobalRoleSessionSet(name);
|
|
397
|
+
let binding = sessions?.providerBinding ?? null;
|
|
398
|
+
if (action !== "show") {
|
|
399
|
+
if (sessions === null || binding === null)
|
|
400
|
+
throw usageError("No current Provider Session.");
|
|
401
|
+
binding = controlProviderRetry(binding, action, Date.now());
|
|
402
|
+
tx.saveGlobalRoleSessionSet({ ...sessions, providerBinding: binding });
|
|
403
|
+
settleGlobalRetryInput(tx, name, binding, new Date());
|
|
404
|
+
}
|
|
405
|
+
return options.jsonOutput ? JSON.stringify({
|
|
406
|
+
roleName: name, retry: providerRetryProjection(binding), disabled: binding?.retryDisabled ?? false
|
|
407
|
+
}) + "\n" : renderProviderRetry(binding) + "\n";
|
|
408
|
+
});
|
|
409
|
+
}
|
|
328
410
|
if (command !== "record" && command !== "replace") {
|
|
329
411
|
throw usageError("Session usage: yui session record|replace <role> --native-id <id> [--reason <reason>].");
|
|
330
412
|
}
|
|
@@ -404,6 +486,259 @@ function assertSessionProvenance(command, role, nativeSessionId, environment) {
|
|
|
404
486
|
throw usageError("Native session id does not match CODEX_THREAD_ID.");
|
|
405
487
|
}
|
|
406
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* The shared application-layer primitive for a durable Global Role input
|
|
491
|
+
* (decision-3 §7: CLI and Web/API share one entry, not per-surface fallbacks).
|
|
492
|
+
* It persists the Global-owned Message with an explicit owner (the Role name)
|
|
493
|
+
* and a stable requestId, idempotent exactly like a Task input: an exact repeat
|
|
494
|
+
* of the same requestId returns the original Message, and any different body,
|
|
495
|
+
* action, or expectedTarget under the same id is a conflicting reuse, never a
|
|
496
|
+
* silent second input (decision-3 §6). It never fabricates a Task or a native
|
|
497
|
+
* Turn; it only records intent.
|
|
498
|
+
*/
|
|
499
|
+
export function sendGlobalRoleMessageCommand(store, roleName, body, author, inputControl, now) {
|
|
500
|
+
if (!body.trim())
|
|
501
|
+
throw usageError("Message body is required.");
|
|
502
|
+
const existing = store.listGlobalRoleMessages(roleName).find((entry) => (entry.inputControl ?? entry.interruptThen?.reusedInput)?.requestId === inputControl.requestId);
|
|
503
|
+
if (existing !== undefined) {
|
|
504
|
+
const original = existing.inputControl ?? existing.interruptThen?.reusedInput;
|
|
505
|
+
if (original?.action !== inputControl.action
|
|
506
|
+
|| existing.body !== body
|
|
507
|
+
|| original.expectedTarget !== inputControl.expectedTarget) {
|
|
508
|
+
throw usageError(`Input requestId ${inputControl.requestId} was already used with different content or target; use a new requestId for a new input.`);
|
|
509
|
+
}
|
|
510
|
+
return { message: existing, idempotentReplay: true };
|
|
511
|
+
}
|
|
512
|
+
const kind = author.type;
|
|
513
|
+
let message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), roleName, body, kind, author, now, { inputControl });
|
|
514
|
+
const sessions = store.getGlobalRoleSessionSet(roleName);
|
|
515
|
+
const session = sessions?.sessions[sessions.activeAgentId];
|
|
516
|
+
if (session !== undefined)
|
|
517
|
+
message = { ...message,
|
|
518
|
+
deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId } };
|
|
519
|
+
store.saveGlobalRoleMessage(message);
|
|
520
|
+
return { message, idempotentReplay: false };
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* `yui role message queue|steer <role> ...` — the durable Global input actions.
|
|
524
|
+
* `queue` persists and returns queued (delivered at the Role's next legal
|
|
525
|
+
* execution opportunity by the existing global-role-runtime mailbox, never a new
|
|
526
|
+
* private queue). `steer` persists then resolves the exact current native Turn
|
|
527
|
+
* from durable state; on a ready resolution it returns a structured live intent
|
|
528
|
+
* the CLI performs against the Agent Host with scope "global", and on any
|
|
529
|
+
* explicit failure it returns the saved Message plus the exact code and never
|
|
530
|
+
* falls back to interrupt or queue (decision-3 §1/§5).
|
|
531
|
+
*/
|
|
532
|
+
function globalRoleMessage(args, store, options) {
|
|
533
|
+
const [action, rawName, ...tail] = args;
|
|
534
|
+
if (action !== "queue" && action !== "steer") {
|
|
535
|
+
throw usageError("Role message usage: yui role message queue|steer <role> (<body>|--body-file <path|->) --request-id <id> [--expected-target <turn>].");
|
|
536
|
+
}
|
|
537
|
+
const usage = action === "queue"
|
|
538
|
+
? "Role message queue usage: yui role message queue <role> (<body>|--body-file <path|->) --request-id <id>."
|
|
539
|
+
: "Role message steer usage: yui role message steer <role> (<body>|--body-file <path|->) --request-id <id> --expected-target <turn>.";
|
|
540
|
+
const name = roleName(rawName);
|
|
541
|
+
// A leading positional before the flags is the inline body; the rest are flags.
|
|
542
|
+
const positionalBody = tail.length > 0 && !tail[0].startsWith("--") ? tail[0] : undefined;
|
|
543
|
+
const flags = positionalBody === undefined ? tail : tail.slice(1);
|
|
544
|
+
const parsed = parseOptions(flags, new Map([
|
|
545
|
+
["--body-file", false],
|
|
546
|
+
["--request-id", false],
|
|
547
|
+
...(action === "steer" ? [["--expected-target", false]] : [])
|
|
548
|
+
]));
|
|
549
|
+
const body = readCommandText(positionalBody, parsed.one("--body-file"), "--body", usage);
|
|
550
|
+
const requestId = required(parsed.one("--request-id"), "--request-id");
|
|
551
|
+
const expectedTarget = action === "steer"
|
|
552
|
+
? required(parsed.one("--expected-target"), "--expected-target")
|
|
553
|
+
: undefined;
|
|
554
|
+
const now = new Date();
|
|
555
|
+
// The durable Global input is authored as an operator input: a Global Role has
|
|
556
|
+
// no Task Leader, and the operator is the human authority over Global Roles.
|
|
557
|
+
const environment = options.env ?? process.env;
|
|
558
|
+
const author = { type: environment.YUI_SESSION_SCOPE === "global"
|
|
559
|
+
? environment.YUI_ROLE === "operator" ? "operator" : "agent" : "user" };
|
|
560
|
+
const persisted = store.transaction((tx) => {
|
|
561
|
+
requireRole(name, tx);
|
|
562
|
+
return sendGlobalRoleMessageCommand(tx, name, body, author, { action, requestId, ...(expectedTarget === undefined ? {} : { expectedTarget }) }, now);
|
|
563
|
+
});
|
|
564
|
+
if (action === "queue") {
|
|
565
|
+
const state = persisted.idempotentReplay ? "idempotent-replay" : "queued";
|
|
566
|
+
return jsonOrText(options, `Queued Global message ${persisted.message.id} to ${name} (${state}).\n`, { roleName: name, message: persisted.message, delivery: { state } });
|
|
567
|
+
}
|
|
568
|
+
if (persisted.idempotentReplay) {
|
|
569
|
+
return jsonOrText(options, `Steer Global message ${persisted.message.id} already recorded (idempotent-replay).\n`, { roleName: name, message: persisted.message, steer: { state: "idempotent-replay" } });
|
|
570
|
+
}
|
|
571
|
+
const resolution = resolveGlobalInputControl(store, name, "steer", expectedTarget);
|
|
572
|
+
if (resolution.outcome !== "ready") {
|
|
573
|
+
return jsonOrText(options, `Steer Global message ${persisted.message.id} saved but not delivered (${resolution.code}: ${resolution.detail}).\n`, { roleName: name, message: persisted.message,
|
|
574
|
+
steer: { state: "not-steered", code: resolution.code, detail: resolution.detail } });
|
|
575
|
+
}
|
|
576
|
+
store.updateGlobalRoleMessage({ ...persisted.message, control: {
|
|
577
|
+
requestId, receiptId: `steer:${name}/${persisted.message.id}`,
|
|
578
|
+
outcome: "pending", observedAt: now.toISOString()
|
|
579
|
+
} });
|
|
580
|
+
return {
|
|
581
|
+
kind: "input-steer",
|
|
582
|
+
roleName: name,
|
|
583
|
+
messageId: persisted.message.id,
|
|
584
|
+
target: resolution.target,
|
|
585
|
+
receiptId: `steer:${name}/${persisted.message.id}`,
|
|
586
|
+
text: body,
|
|
587
|
+
output: `Steering ${name} at Turn ${resolution.target.nativeTurnId ?? resolution.target.attemptId} with message ${persisted.message.id}.\n`
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* `yui role interrupt <role> --expected-target <turn> [--then-message <ref>]` —
|
|
592
|
+
* a live interrupt of a Global Role's exact current native Turn. A bare
|
|
593
|
+
* interrupt persists no Message (it is pure control); a `--then-message` names
|
|
594
|
+
* an already-persisted durable Global Message to deliver once after a proven
|
|
595
|
+
* terminal — the ordered composition of interrupt and an existing queue, not a
|
|
596
|
+
* fourth action and not an auto-fallback (decision-3 §4).
|
|
597
|
+
*/
|
|
598
|
+
function globalRoleInterrupt(args, store, options) {
|
|
599
|
+
const usage = "Role interrupt usage: yui role interrupt <role> --expected-target <turn> [--then-message <global-message-id>] [--request-id <id>].";
|
|
600
|
+
const [rawName, ...tail] = args;
|
|
601
|
+
const name = roleName(rawName);
|
|
602
|
+
const parsed = parseOptions(tail, new Map([
|
|
603
|
+
["--expected-target", false],
|
|
604
|
+
["--then-message", false],
|
|
605
|
+
["--request-id", false]
|
|
606
|
+
]));
|
|
607
|
+
const expectedTarget = required(parsed.one("--expected-target"), "--expected-target");
|
|
608
|
+
const thenMessageId = trimmed(parsed.one("--then-message"));
|
|
609
|
+
const requestId = trimmed(parsed.one("--request-id")) ?? `cancel:${encodeURIComponent(expectedTarget)}`;
|
|
610
|
+
const fingerprint = JSON.stringify({ expectedTarget, thenMessageId });
|
|
611
|
+
const prior = store.getGlobalRoleSessionSet(name)?.interrupts?.[requestId];
|
|
612
|
+
if (prior !== undefined) {
|
|
613
|
+
if (prior.fingerprint !== fingerprint)
|
|
614
|
+
throw usageError("Interrupt requestId already names another control.");
|
|
615
|
+
return jsonOrText(options, `Interrupt ${requestId} already recorded; no native control repeated.\n`, { roleName: name, interrupt: prior.receipt ?? { state: "interrupt-unknown" }, idempotentReplay: true });
|
|
616
|
+
}
|
|
617
|
+
const resolved = store.transaction((tx) => {
|
|
618
|
+
requireRole(name, tx);
|
|
619
|
+
const resolution = resolveGlobalInputControl(tx, name, "interrupt", expectedTarget);
|
|
620
|
+
if (resolution.outcome !== "ready")
|
|
621
|
+
return resolution;
|
|
622
|
+
const sessions = tx.getGlobalRoleSessionSet(name);
|
|
623
|
+
if (Object.values(sessions.interrupts ?? {}).some(entry => entry.attemptId === resolution.target.attemptId
|
|
624
|
+
&& entry.nativeSessionId === resolution.target.nativeSessionId
|
|
625
|
+
&& !(entry.receipt?.state === "interrupt-unavailable" && entry.receipt.outcome === "rejected"))) {
|
|
626
|
+
return { outcome: "then-conflict", code: "TARGET_CHANGED",
|
|
627
|
+
detail: "This exact native Turn already has a recorded cancel request; inspect it instead of repeating the control." };
|
|
628
|
+
}
|
|
629
|
+
// decision-3 §4: before the live cancel, register the continuation claim on
|
|
630
|
+
// the already-persisted durable Global Message in one short transaction, so
|
|
631
|
+
// the handoff relationship, its target Turn, and its stable requestId are
|
|
632
|
+
// durable independent of the cancel outcome. An explicit then-Message must be
|
|
633
|
+
// an already-persisted durable Global Message owned by this exact Role; it is
|
|
634
|
+
// never created here and never a Task record (decision-3 §4/§9).
|
|
635
|
+
if (thenMessageId !== undefined) {
|
|
636
|
+
const claim = registerGlobalInterruptThen(tx, name, thenMessageId, resolution.target.attemptId, resolution.target.nativeTurnId, requestId, resolution.target);
|
|
637
|
+
if (claim !== "claimed")
|
|
638
|
+
return { outcome: "then-conflict", ...claim };
|
|
639
|
+
}
|
|
640
|
+
tx.saveGlobalRoleSessionSet({ ...sessions, interrupts: { ...sessions.interrupts,
|
|
641
|
+
[requestId]: { fingerprint, attemptId: resolution.target.attemptId,
|
|
642
|
+
nativeSessionId: resolution.target.nativeSessionId,
|
|
643
|
+
receiptId: `interrupt:${name}/${requestId}` }
|
|
644
|
+
} });
|
|
645
|
+
return resolution;
|
|
646
|
+
});
|
|
647
|
+
if (resolved.outcome === "then-conflict") {
|
|
648
|
+
return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
|
|
649
|
+
}
|
|
650
|
+
if (resolved.outcome !== "ready") {
|
|
651
|
+
return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
kind: "input-interrupt",
|
|
655
|
+
roleName: name,
|
|
656
|
+
target: resolved.target,
|
|
657
|
+
receiptId: `interrupt:${name}/${requestId}`,
|
|
658
|
+
...(thenMessageId === undefined ? {} : { thenMessageId }),
|
|
659
|
+
output: `Interrupting ${name} at Turn ${resolved.target.nativeTurnId ?? resolved.target.attemptId}`
|
|
660
|
+
+ `${thenMessageId === undefined ? "" : `, then delivering ${thenMessageId} once after a proven terminal`}.\n`
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Bind a saved durable Global Message as the single interrupt-then continuation
|
|
665
|
+
* of an exact interrupted native Turn (decision-3 §4). The Global twin of the
|
|
666
|
+
* Task `registerInterruptThen`: a Global Role has no AgentRun, so the
|
|
667
|
+
* target is proven by the exact native Turn's attemptId under the Role's own
|
|
668
|
+
* writer fence, never by a fabricated Task Run. Idempotent per interrupt
|
|
669
|
+
* requestId; only one continuation may claim a given target Turn; a Message that
|
|
670
|
+
* was already delivered or already claims another target is refused, never
|
|
671
|
+
* silently dropped or duplicated.
|
|
672
|
+
*/
|
|
673
|
+
function registerGlobalInterruptThen(store, roleName, thenMessageId, targetAttemptId, targetNativeTurnId, requestId, target) {
|
|
674
|
+
const message = store.listGlobalRoleMessages(roleName).find((entry) => entry.id === thenMessageId);
|
|
675
|
+
if (message === undefined) {
|
|
676
|
+
throw usageError(`Then-Message ${thenMessageId} is not a durable Global Message owned by ${roleName}.`);
|
|
677
|
+
}
|
|
678
|
+
if (message.notDelivered !== undefined) {
|
|
679
|
+
return { code: "TARGET_CHANGED", detail: `The saved input is already settled not-delivered (${message.notDelivered.reason}).` };
|
|
680
|
+
}
|
|
681
|
+
if (message.control !== undefined && message.control.outcome !== "rejected") {
|
|
682
|
+
return { code: "TARGET_CHANGED", detail: "The saved input is already accepted or its delivery remains unconfirmed." };
|
|
683
|
+
}
|
|
684
|
+
if (message.deliveryTarget !== undefined
|
|
685
|
+
&& (message.deliveryTarget.nativeSessionId !== target.nativeSessionId
|
|
686
|
+
|| message.deliveryTarget.agentId !== target.agentId)) {
|
|
687
|
+
return { code: "TARGET_CHANGED", detail: "The saved input belongs to another native Session." };
|
|
688
|
+
}
|
|
689
|
+
// A synthesized fallback id is an opaque idempotency key (compared only for
|
|
690
|
+
// equality, never parsed), so it must satisfy the same safe-identity rule as a
|
|
691
|
+
// caller-supplied requestId: no path separators. Use `:` as the field joiner —
|
|
692
|
+
// the receiptId carries the `/`-shaped human receipt, this key stays slash-free.
|
|
693
|
+
const claimRequestId = requestId ?? `interrupt:${roleName}:${encodeURIComponent(targetAttemptId)}`;
|
|
694
|
+
// Idempotent per interrupt requestId: an exact repeat of the same claim is a
|
|
695
|
+
// no-op that still authorizes the live cancel.
|
|
696
|
+
if (message.interruptThen !== undefined) {
|
|
697
|
+
const claim = message.interruptThen;
|
|
698
|
+
const prior = store.getGlobalRoleSessionSet(roleName)?.interrupts?.[claim.requestId];
|
|
699
|
+
const canReferenceClaim = claim.requestId === claimRequestId
|
|
700
|
+
|| (prior?.receipt?.state === "interrupt-unavailable" && prior.receipt.outcome === "rejected");
|
|
701
|
+
if (canReferenceClaim && message.delivery === undefined && message.notDelivered === undefined
|
|
702
|
+
&& claim.targetAttemptId === targetAttemptId
|
|
703
|
+
&& claim.targetAgentId === target.agentId && claim.targetNativeSessionId === target.nativeSessionId
|
|
704
|
+
&& claim.targetAuthorityEpoch === target.authority.epoch
|
|
705
|
+
&& claim.targetAuthorityHolderId === target.authority.holderId)
|
|
706
|
+
return "claimed";
|
|
707
|
+
return { code: "TARGET_CHANGED",
|
|
708
|
+
detail: `Message ${thenMessageId} already claims a continuation of Turn ${message.interruptThen.targetAttemptId}.` };
|
|
709
|
+
}
|
|
710
|
+
// A Message that already delivered is a settled fact, not a fresh input to
|
|
711
|
+
// reuse; reusing it would replay a consumed queue entry.
|
|
712
|
+
if (message.delivery !== undefined) {
|
|
713
|
+
return { code: "TARGET_CHANGED",
|
|
714
|
+
detail: `Message ${thenMessageId} was already delivered at ${message.delivery.deliveredAt}.` };
|
|
715
|
+
}
|
|
716
|
+
// Only one continuation may claim a given target Turn.
|
|
717
|
+
const existing = store.listGlobalRoleMessages(roleName).find((entry) => entry.interruptThen?.targetAttemptId === targetAttemptId && entry.id !== message.id);
|
|
718
|
+
if (existing !== undefined) {
|
|
719
|
+
return { code: "TARGET_CHANGED",
|
|
720
|
+
detail: `Turn ${targetAttemptId} is already the terminal target of Message ${existing.id}.` };
|
|
721
|
+
}
|
|
722
|
+
store.updateGlobalRoleMessage(claimGlobalRoleMessageInterruptThen(message, {
|
|
723
|
+
requestId: claimRequestId, targetAttemptId,
|
|
724
|
+
targetNativeSessionId: target.nativeSessionId, targetAgentId: target.agentId,
|
|
725
|
+
targetAuthorityEpoch: target.authority.epoch,
|
|
726
|
+
targetAuthorityHolderId: target.authority.holderId,
|
|
727
|
+
...(targetNativeTurnId === undefined ? {} : { targetNativeTurnId })
|
|
728
|
+
}));
|
|
729
|
+
return "claimed";
|
|
730
|
+
}
|
|
731
|
+
/** Emit a plain string or, under --json, a JSON envelope, matching the Global
|
|
732
|
+
* Role command surface's existing string/JSON split. */
|
|
733
|
+
function jsonOrText(options, text, data) {
|
|
734
|
+
const record = data;
|
|
735
|
+
const failure = record.steer?.code === undefined ? record.interrupt : record.steer;
|
|
736
|
+
if (failure?.code !== undefined)
|
|
737
|
+
options.onInputFailure?.({
|
|
738
|
+
code: failure.code, detail: failure.detail ?? failure.code, data
|
|
739
|
+
});
|
|
740
|
+
return options.jsonOutput === true ? JSON.stringify(data) : text;
|
|
741
|
+
}
|
|
407
742
|
function parseOptions(args, specs) {
|
|
408
743
|
const values = new Map();
|
|
409
744
|
const seen = new Set();
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
-
import { usageError } from "../errors/cliError.js";
|
|
4
|
+
import { CliError, usageError } from "../errors/cliError.js";
|
|
5
5
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
6
6
|
import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../repository/checkoutSwap.js";
|
|
7
|
-
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
7
|
+
import { GitWorkspaceRefreshError, 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;
|
|
@@ -21,10 +22,14 @@ export async function runProjectCommand(args, store, options = {}) {
|
|
|
21
22
|
}
|
|
22
23
|
if (command === "refresh") {
|
|
23
24
|
const refreshed = await refreshProject(rest, store, options);
|
|
25
|
+
const head = refreshed.changed
|
|
26
|
+
? `Refreshed project ${refreshed.project.id}: ${refreshed.fromCommit} -> ${refreshed.toCommit}`
|
|
27
|
+
: `Project ${refreshed.project.id} HEAD is already current at ${refreshed.toCommit}`;
|
|
28
|
+
const tracking = refreshed.tracking;
|
|
24
29
|
return {
|
|
25
|
-
output:
|
|
26
|
-
? `
|
|
27
|
-
: `
|
|
30
|
+
output: `${head}\n` + (tracking.status === "unmanaged"
|
|
31
|
+
? `Tracking unmanaged: ${tracking.reason}\n`
|
|
32
|
+
: `Tracking ${tracking.status}: ${tracking.ref} at ${tracking.toCommit}\n`),
|
|
28
33
|
data: refreshed
|
|
29
34
|
};
|
|
30
35
|
}
|
|
@@ -100,14 +105,23 @@ async function refreshProject(args, store, options) {
|
|
|
100
105
|
// RFC Phase 1: hold the per-Project maintenance fence for the whole refresh
|
|
101
106
|
// so Task workspace preparation and other maintenance cannot interleave
|
|
102
107
|
// with the canonical branch/working-tree move.
|
|
103
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
108
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
104
109
|
try {
|
|
110
|
+
const current = requireUnchangedMaintenanceProject(store, project, "refresh");
|
|
105
111
|
const refreshed = await (options.git ?? new NodeGitWorkspace()).refresh({
|
|
106
|
-
repositoryPath:
|
|
107
|
-
remoteUrl:
|
|
108
|
-
stableRef:
|
|
112
|
+
repositoryPath: current.path,
|
|
113
|
+
remoteUrl: current.remoteUrl,
|
|
114
|
+
stableRef: current.stableBranch
|
|
109
115
|
});
|
|
110
|
-
return { project, ...refreshed };
|
|
116
|
+
return { project: current, ...refreshed };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof GitWorkspaceRefreshError) {
|
|
120
|
+
throw new CliError("RUNTIME_ERROR", error.message, undefined, {
|
|
121
|
+
projectId: project.id, refresh: error.result
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
111
125
|
}
|
|
112
126
|
finally {
|
|
113
127
|
releaseMaintenance();
|
|
@@ -130,8 +144,9 @@ async function diagnoseProject(args, store, options) {
|
|
|
130
144
|
};
|
|
131
145
|
}
|
|
132
146
|
const git = options.git ?? new NodeGitWorkspace();
|
|
133
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
147
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
134
148
|
try {
|
|
149
|
+
requireUnchangedMaintenanceProject(store, project, "diagnose");
|
|
135
150
|
const current = await git.inspect(project.path, "HEAD");
|
|
136
151
|
const remote = await git.resolveRemoteBaseline({
|
|
137
152
|
repositoryPath: project.path,
|
|
@@ -235,7 +250,7 @@ async function cloneProject(args, store, options) {
|
|
|
235
250
|
if (dirname(destination) !== workspaceRoot) {
|
|
236
251
|
throw usageError("Project clone destination must be directly inside the configured workspace.");
|
|
237
252
|
}
|
|
238
|
-
assertOutsideManagedWorktrees(destination,
|
|
253
|
+
assertOutsideManagedWorktrees(destination, store.rootDirectory());
|
|
239
254
|
ownership = "external";
|
|
240
255
|
}
|
|
241
256
|
else {
|
|
@@ -306,13 +321,14 @@ async function migrateProject(args, store, options) {
|
|
|
306
321
|
// Migration rewrites the Project's Git repository: hold the per-Project
|
|
307
322
|
// maintenance fence so no rebuild/archive/cleanup (or a second migrate)
|
|
308
323
|
// interleaves, and the Controller defers worktree preparation meanwhile.
|
|
309
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
324
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
310
325
|
try {
|
|
311
326
|
// Re-read the Project under the fence. A concurrent migration may have
|
|
312
327
|
// completed and switched the catalog to the Home-managed repo since the
|
|
313
328
|
// record was resolved above; the stale snapshot must not drive any Git
|
|
314
329
|
// effect (in particular it must not delete the now-canonical repo).
|
|
315
330
|
const current = requireProject(store, project.id);
|
|
331
|
+
assertProjectActive(current, "migrate");
|
|
316
332
|
if (current.ownership === "managed") {
|
|
317
333
|
throw usageError(`Project is already Home-managed: ${project.id}.`);
|
|
318
334
|
}
|
|
@@ -461,10 +477,10 @@ async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, bran
|
|
|
461
477
|
async function addProject(args, store, options) {
|
|
462
478
|
const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
|
|
463
479
|
const parsed = parseAddArguments(args, usage);
|
|
464
|
-
assertOutsideManagedWorktrees(parsed.path, store.
|
|
480
|
+
assertOutsideManagedWorktrees(parsed.path, store.rootDirectory());
|
|
465
481
|
const git = options.git ?? new NodeGitWorkspace();
|
|
466
482
|
const head = await git.inspect(parsed.path, "HEAD");
|
|
467
|
-
assertOutsideManagedWorktrees(head.root, store.
|
|
483
|
+
assertOutsideManagedWorktrees(head.root, store.rootDirectory());
|
|
468
484
|
if (!await git.isClean(head.root)) {
|
|
469
485
|
throw usageError("Project checkout must be clean before it can be registered.");
|
|
470
486
|
}
|
|
@@ -587,7 +603,7 @@ async function resetProject(args, store, options) {
|
|
|
587
603
|
throw usageError(`Project reset requires matching stable and development branches: ${project.id}.`);
|
|
588
604
|
}
|
|
589
605
|
const git = options.git ?? new NodeGitWorkspace();
|
|
590
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
606
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
591
607
|
try {
|
|
592
608
|
// Re-read under the fence so a concurrent catalog change can never drive
|
|
593
609
|
// a destructive Git effect from a stale snapshot.
|
|
@@ -712,7 +728,7 @@ async function replaceProject(args, store, options) {
|
|
|
712
728
|
+ "Re-run with --discard-local to acknowledge that the checkout and any uncommitted state will be discarded.");
|
|
713
729
|
}
|
|
714
730
|
const git = options.git ?? new NodeGitWorkspace();
|
|
715
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
731
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
716
732
|
try {
|
|
717
733
|
const current = requireProject(store, project.id);
|
|
718
734
|
assertProjectActive(current, "replace");
|
|
@@ -921,8 +937,9 @@ async function deleteProjectCommand(args, store, options) {
|
|
|
921
937
|
// every failure restores it, so the catalog never loses its recoverable
|
|
922
938
|
// entry while a live checkout (or its failure) is still in play.
|
|
923
939
|
const tombstone = join(store.rootDirectory(), "projects", `.delete-${project.id}`);
|
|
924
|
-
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
940
|
+
const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
|
|
925
941
|
try {
|
|
942
|
+
requireUnchangedMaintenanceProject(store, project, "delete");
|
|
926
943
|
// Heal a crashed earlier attempt before the prechecks turn.
|
|
927
944
|
await healCheckoutSwap({ currentPath: project.path, backupPath: tombstone });
|
|
928
945
|
if (existsSync(project.path)) {
|
|
@@ -1097,10 +1114,8 @@ function assertProjectAvailable(store, project, exceptId) {
|
|
|
1097
1114
|
}
|
|
1098
1115
|
}
|
|
1099
1116
|
}
|
|
1100
|
-
function assertOutsideManagedWorktrees(path,
|
|
1101
|
-
|
|
1102
|
-
return;
|
|
1103
|
-
const managedRoot = join(resolve(workspace), "worktree");
|
|
1117
|
+
function assertOutsideManagedWorktrees(path, home) {
|
|
1118
|
+
const managedRoot = managedWorkspacesRoot(home);
|
|
1104
1119
|
const candidate = resolve(path);
|
|
1105
1120
|
const fromManagedRoot = relative(managedRoot, candidate);
|
|
1106
1121
|
const toManagedRoot = relative(candidate, managedRoot);
|
|
@@ -1290,6 +1305,19 @@ function projectKnowledge(args, store, options) {
|
|
|
1290
1305
|
? "Project knowledge command is required."
|
|
1291
1306
|
: `Unknown command: project knowledge ${command}`);
|
|
1292
1307
|
}
|
|
1308
|
+
/** Waiting yields to catalog writers too; never apply stale Git/lifecycle inputs. */
|
|
1309
|
+
function requireUnchangedMaintenanceProject(store, expected, action) {
|
|
1310
|
+
const current = requireProject(store, expected.id);
|
|
1311
|
+
if (current.path !== expected.path
|
|
1312
|
+
|| current.remoteUrl !== expected.remoteUrl
|
|
1313
|
+
|| current.stableBranch !== expected.stableBranch
|
|
1314
|
+
|| current.developmentBranch !== expected.developmentBranch
|
|
1315
|
+
|| current.ownership !== expected.ownership
|
|
1316
|
+
|| current.status !== expected.status) {
|
|
1317
|
+
throw new Error(`Project changed while waiting to ${action}: ${expected.id}; read current state before retrying.`);
|
|
1318
|
+
}
|
|
1319
|
+
return current;
|
|
1320
|
+
}
|
|
1293
1321
|
function requireProject(store, reference) {
|
|
1294
1322
|
const project = resolveProject(store.listProjects(), reference);
|
|
1295
1323
|
if (project === null)
|
|
@@ -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)
|