@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
|
@@ -52,9 +52,17 @@ export function requireResolutionDecision(attempt, report, now) {
|
|
|
52
52
|
updatedAt: now.toISOString()
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
export function recordIntegrationConflict(attempt, report, now) {
|
|
56
|
+
if (attempt.status !== "running" && attempt.status !== "conflicted") {
|
|
57
|
+
throw new Error(`Integration cannot record Git conflicts from ${attempt.status}.`);
|
|
58
|
+
}
|
|
59
|
+
return updateIntegrationAttempt(attempt, {
|
|
60
|
+
status: "conflicted", conflict: normalizeConflictReport(report)
|
|
61
|
+
}, now);
|
|
62
|
+
}
|
|
55
63
|
export function recordResolutionDecision(attempt, decision, decidedBy, now) {
|
|
56
64
|
validateIntegrationAttempt(attempt);
|
|
57
|
-
if (attempt.status
|
|
65
|
+
if (!["blocked", "conflicted"].includes(attempt.status) || attempt.conflict === undefined) {
|
|
58
66
|
throw new Error("Integration has no pending semantic decision.");
|
|
59
67
|
}
|
|
60
68
|
if (decision.action !== "manual-resolution" && decision.action !== "reject") {
|
|
@@ -63,7 +71,7 @@ export function recordResolutionDecision(attempt, decision, decidedBy, now) {
|
|
|
63
71
|
const timestamp = now.toISOString();
|
|
64
72
|
return validateIntegrationAttempt({
|
|
65
73
|
...attempt,
|
|
66
|
-
status: decision.action === "reject" ? "failed" :
|
|
74
|
+
status: decision.action === "reject" ? "failed" : attempt.status,
|
|
67
75
|
resolution: {
|
|
68
76
|
action: decision.action,
|
|
69
77
|
rationale: requireText(decision.rationale, "Resolution rationale"),
|
|
@@ -138,6 +146,23 @@ export function validateIntegrationAttempt(attempt) {
|
|
|
138
146
|
if (attempt.candidateCommit !== undefined) {
|
|
139
147
|
requireCommit(attempt.candidateCommit, "Integration candidate commit");
|
|
140
148
|
}
|
|
149
|
+
if (attempt.checkInputDigest !== undefined && !/^[a-f0-9]{64}$/u.test(attempt.checkInputDigest)) {
|
|
150
|
+
throw new Error("Integration check input digest is invalid.");
|
|
151
|
+
}
|
|
152
|
+
if (attempt.sourceProgress !== undefined) {
|
|
153
|
+
const progress = attempt.sourceProgress;
|
|
154
|
+
requireText(progress.workspace, "Integration source workspace");
|
|
155
|
+
requireText(progress.branch, "Integration source branch");
|
|
156
|
+
requireCommit(progress.head, "Integration source head");
|
|
157
|
+
if (!/^[a-f0-9]{64}$/u.test(progress.sourceDigest)
|
|
158
|
+
|| !Number.isSafeInteger(progress.completedSteps) || progress.completedSteps < 0
|
|
159
|
+
|| (progress.activeAction !== undefined
|
|
160
|
+
&& !/^yui-integration-[a-f0-9]{32}$/u.test(progress.activeAction))
|
|
161
|
+
|| (progress.emptyResolution !== undefined
|
|
162
|
+
&& (progress.emptyResolution !== true || progress.activeAction === undefined))) {
|
|
163
|
+
throw new Error("Integration source progress is invalid.");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
141
166
|
if (attempt.afterCommit !== undefined) {
|
|
142
167
|
requireCommit(attempt.afterCommit, "Integration after commit");
|
|
143
168
|
}
|
|
@@ -160,6 +185,7 @@ export function validateIntegrationAttempt(attempt) {
|
|
|
160
185
|
}
|
|
161
186
|
if (![
|
|
162
187
|
"running",
|
|
188
|
+
"conflicted",
|
|
163
189
|
"blocked",
|
|
164
190
|
"validating",
|
|
165
191
|
"committed",
|
|
@@ -170,8 +196,8 @@ export function validateIntegrationAttempt(attempt) {
|
|
|
170
196
|
}
|
|
171
197
|
if (attempt.conflict !== undefined)
|
|
172
198
|
normalizeConflictReport(attempt.conflict);
|
|
173
|
-
if (attempt.status === "blocked" && attempt.conflict === undefined) {
|
|
174
|
-
throw new Error("A
|
|
199
|
+
if ((attempt.status === "blocked" || attempt.status === "conflicted") && attempt.conflict === undefined) {
|
|
200
|
+
throw new Error("A paused Integration needs a ConflictReport.");
|
|
175
201
|
}
|
|
176
202
|
if (attempt.resolution !== undefined) {
|
|
177
203
|
if (attempt.resolution.action !== "manual-resolution"
|
|
@@ -463,9 +463,9 @@ export async function processIntegrationQueue(store, home, taskId, options = {})
|
|
|
463
463
|
if (result.status === "committed") {
|
|
464
464
|
entry = markIntegrationQueueCommitted(entry, committedTargetAfter(result.attempt), now());
|
|
465
465
|
}
|
|
466
|
-
else if (result.status === "blocked") {
|
|
466
|
+
else if (result.status === "blocked" || result.status === "conflicted") {
|
|
467
467
|
entry = markIntegrationQueueBlocked(entry, result.attempt.conflict?.summary
|
|
468
|
-
??
|
|
468
|
+
?? result.attempt.summary ?? "Inspect the unfinished Integration.", now());
|
|
469
469
|
}
|
|
470
470
|
else {
|
|
471
471
|
entry = markIntegrationQueueBlocked(entry, gateFailureSummary(result.attempt), now());
|
|
@@ -580,7 +580,7 @@ function resolveBlockedAttempt(store, taskId, entry, decision, decidedBy, now) {
|
|
|
580
580
|
if (entry.integrationAttemptId === undefined)
|
|
581
581
|
return;
|
|
582
582
|
const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
|
|
583
|
-
if (attempt === null || attempt.status !== "blocked")
|
|
583
|
+
if (attempt === null || (attempt.status !== "blocked" && attempt.status !== "conflicted"))
|
|
584
584
|
return;
|
|
585
585
|
const rejected = recordResolutionDecision(attempt, {
|
|
586
586
|
action: "reject",
|
|
@@ -601,7 +601,7 @@ function assertRecoverableAttempt(store, taskId, entry, action) {
|
|
|
601
601
|
const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
|
|
602
602
|
if (attempt === null)
|
|
603
603
|
return;
|
|
604
|
-
if (attempt.status !== "blocked" && attempt.status !== "failed") {
|
|
604
|
+
if (attempt.status !== "blocked" && attempt.status !== "conflicted" && attempt.status !== "failed") {
|
|
605
605
|
throw new Error(`Integration queue entry ${entry.id} is backed by ${attempt.status} `
|
|
606
606
|
+ `Integration Attempt ${attempt.id}; reconcile the queue entry instead `
|
|
607
607
|
+ `of ${action} it.`);
|
|
@@ -679,9 +679,9 @@ async function reconcileTerminalAttempts(store, taskId, git, now) {
|
|
|
679
679
|
// it. An Attempt still running or validating is genuinely in flight.
|
|
680
680
|
if (entry.status !== "running")
|
|
681
681
|
continue;
|
|
682
|
-
if (attempt.status !== "failed" && attempt.status !== "blocked")
|
|
682
|
+
if (attempt.status !== "failed" && attempt.status !== "blocked" && attempt.status !== "conflicted")
|
|
683
683
|
continue;
|
|
684
|
-
const diagnosis = attempt.status === "blocked"
|
|
684
|
+
const diagnosis = attempt.status === "blocked" || attempt.status === "conflicted"
|
|
685
685
|
? attempt.conflict?.summary ?? "Integration blocked without a conflict report."
|
|
686
686
|
: gateFailureSummary(attempt);
|
|
687
687
|
store.saveIntegrationQueueEntry(taskId, markIntegrationQueueBlocked(entry, diagnosis, now()));
|
|
@@ -742,7 +742,7 @@ function committedTargetAfter(attempt) {
|
|
|
742
742
|
function gateFailureSummary(attempt) {
|
|
743
743
|
const failed = (attempt.checks ?? []).find((check) => check.outcome === "failed");
|
|
744
744
|
return failed === undefined
|
|
745
|
-
?
|
|
745
|
+
? attempt.summary ?? "Inspect the Integration target and preserved evidence."
|
|
746
746
|
: `gate failed: ${failed.name}${failed.details === undefined ? "" : `: ${failed.details}`}`;
|
|
747
747
|
}
|
|
748
748
|
async function findEquivalentCommit(git, repositoryPath, sourceCommit, historyHead, baseCommit) {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
|
|
7
|
+
import { recordIntegrationConflict, requireResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
/** Each Git ref-changing command has an attempt-owned cursor and reflog action.
|
|
10
|
+
* Re-entry consumes the exact branch ref transition, never clean HEAD alone.
|
|
11
|
+
* Git's own in-progress metadata identifies an unfinished operation. Missing
|
|
12
|
+
* evidence is a diagnosis for the Leader, not permission to replay an effect. */
|
|
13
|
+
export async function applyIntegrationSource(input) {
|
|
14
|
+
let current = input.attempt;
|
|
15
|
+
const { workspace, store, now } = input;
|
|
16
|
+
const path = workspace.path;
|
|
17
|
+
const source = current.source;
|
|
18
|
+
const save = (patch) => {
|
|
19
|
+
const next = updateIntegrationAttempt(current, patch, now());
|
|
20
|
+
store.saveIntegrationAttempt(current.taskId, next);
|
|
21
|
+
current = next;
|
|
22
|
+
};
|
|
23
|
+
const pause = (affectedPaths, kind) => {
|
|
24
|
+
const pending = recordIntegrationConflict(current, {
|
|
25
|
+
affectedPaths, summary: `${kind} conflicts in ${current.targetRef}; resolve files and continue.`
|
|
26
|
+
}, now());
|
|
27
|
+
store.transaction(tx => {
|
|
28
|
+
tx.saveIntegrationAttempt(current.taskId, pending);
|
|
29
|
+
if (current.status !== "conflicted"
|
|
30
|
+
|| JSON.stringify(current.conflict?.affectedPaths) !== JSON.stringify(affectedPaths)) {
|
|
31
|
+
queueLeaderWakeup(tx, current.taskId, `integration-conflicted:${current.id}`, now());
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return pending;
|
|
35
|
+
};
|
|
36
|
+
if (source.kind === "historical-change-sets") {
|
|
37
|
+
throw new Error("Historical Integration source has no current application proof; preserve evidence and abort explicitly.");
|
|
38
|
+
}
|
|
39
|
+
if (source.kind === "work-item" && source.strategy === "manual") {
|
|
40
|
+
if (current.candidateCommit !== undefined) {
|
|
41
|
+
await assertIntegrationCandidate(path, current.candidateCommit);
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
if (current.resolution?.action !== "manual-resolution") {
|
|
45
|
+
const pending = requireResolutionDecision(current, {
|
|
46
|
+
affectedPaths: [], summary: "Manual WorkItem integration; apply the result and record its rationale."
|
|
47
|
+
}, now());
|
|
48
|
+
store.saveIntegrationAttempt(current.taskId, pending);
|
|
49
|
+
return pending;
|
|
50
|
+
}
|
|
51
|
+
await assertIntegrationCandidate(path);
|
|
52
|
+
save({ status: "running", candidateCommit: await line(path, "rev-parse", "HEAD") });
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
const sourceDigest = integrationSourceDigest(current, workspace);
|
|
56
|
+
const commits = source.kind === "work-item" && source.strategy === "cherry-pick"
|
|
57
|
+
? (await command(path, ["rev-list", "--reverse", `${source.startCommit}..${source.resultCommit}`]))
|
|
58
|
+
.trim().split("\n").filter(Boolean)
|
|
59
|
+
: [source.kind === "upstream" ? source.remoteCommit : source.resultCommit];
|
|
60
|
+
const kind = source.kind === "upstream" ? "rebase" : source.strategy;
|
|
61
|
+
const branch = workspace.branch.startsWith("refs/") ? workspace.branch : `refs/heads/${workspace.branch}`;
|
|
62
|
+
const active = await activeGitOperation(path);
|
|
63
|
+
if (current.sourceProgress === undefined) {
|
|
64
|
+
// Historical unfinished operations can be adopted only when Git still
|
|
65
|
+
// carries their exact source/target identity. Finished old operations
|
|
66
|
+
// without a receipt are deliberately not inferred from a clean tree.
|
|
67
|
+
const head = await line(path, "rev-parse", "HEAD");
|
|
68
|
+
if (active !== undefined) {
|
|
69
|
+
if (kind === "cherry-pick") {
|
|
70
|
+
throw new Error("Historical cherry-pick has no durable completed-step cursor; preserve it and choose explicit recovery.");
|
|
71
|
+
}
|
|
72
|
+
await assertOperation(path, kind, commits[0], current.beforeCommit, branch, source);
|
|
73
|
+
}
|
|
74
|
+
else if (head !== current.beforeCommit || current.status === "conflicted") {
|
|
75
|
+
throw new Error("Integration source completion is unproven: no attempt-owned Git receipt. Preserve the candidate; do not replay.");
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await assertIntegrationCandidate(path, current.beforeCommit);
|
|
79
|
+
}
|
|
80
|
+
save({ sourceProgress: {
|
|
81
|
+
workspace: resolve(path), branch, sourceDigest, completedSteps: 0,
|
|
82
|
+
head: current.beforeCommit,
|
|
83
|
+
...(active === undefined ? {} : { activeAction: actionId() })
|
|
84
|
+
} });
|
|
85
|
+
}
|
|
86
|
+
let progress = current.sourceProgress;
|
|
87
|
+
if (progress.workspace !== resolve(path) || progress.branch !== branch
|
|
88
|
+
|| progress.sourceDigest !== sourceDigest || progress.completedSteps > commits.length) {
|
|
89
|
+
throw new Error("Integration source/workspace identity changed; no Git operation or check was started.");
|
|
90
|
+
}
|
|
91
|
+
if (current.candidateCommit !== undefined) {
|
|
92
|
+
if (progress.completedSteps !== commits.length || progress.activeAction !== undefined
|
|
93
|
+
|| progress.head !== current.candidateCommit) {
|
|
94
|
+
throw new Error("Integration candidate does not match its source application receipt.");
|
|
95
|
+
}
|
|
96
|
+
await assertIntegrationCandidate(path, current.candidateCommit);
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
for (let index = progress.completedSteps; index < commits.length; index++) {
|
|
100
|
+
const commit = commits[index];
|
|
101
|
+
let fresh = false;
|
|
102
|
+
if (progress.activeAction === undefined) {
|
|
103
|
+
await assertIntegrationCandidate(path, progress.head);
|
|
104
|
+
if (await succeeds(path, ["merge-base", "--is-ancestor", commit, progress.head])) {
|
|
105
|
+
// Already-represented input is proven by ancestry, without a Git effect.
|
|
106
|
+
save({ sourceProgress: { ...progress, completedSteps: index + 1 } });
|
|
107
|
+
progress = current.sourceProgress;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (source.kind === "upstream") {
|
|
111
|
+
if (input.remoteUrl === undefined || input.git.fetchRemoteHeadIntoWorktree === undefined) {
|
|
112
|
+
throw new Error("Upstream Integration has no remote fetch capability.");
|
|
113
|
+
}
|
|
114
|
+
const fetched = await input.git.fetchRemoteHeadIntoWorktree({
|
|
115
|
+
repositoryPath: path, remoteUrl: input.remoteUrl, branch: source.branch
|
|
116
|
+
});
|
|
117
|
+
if (fetched.commit !== commit) {
|
|
118
|
+
throw new Error(`Upstream moved: ${fetched.commit}; expected ${commit}.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
save({ sourceProgress: { ...progress, activeAction: actionId() } });
|
|
122
|
+
progress = current.sourceProgress;
|
|
123
|
+
fresh = true;
|
|
124
|
+
}
|
|
125
|
+
const action = progress.activeAction;
|
|
126
|
+
const operation = await activeGitOperation(path);
|
|
127
|
+
if (!fresh && operation === undefined) {
|
|
128
|
+
await assertIntegrationCandidate(path);
|
|
129
|
+
const provenEmpty = kind === "cherry-pick" && progress.emptyResolution === true
|
|
130
|
+
&& await line(path, "rev-parse", "HEAD") === progress.head
|
|
131
|
+
&& (await command(path, ["symbolic-ref", "HEAD"])).trim() === branch;
|
|
132
|
+
if (!provenEmpty && !await hasExactRefReceipt(path, branch, progress.head, action)) {
|
|
133
|
+
throw new Error("Git application outcome is unproven: exact attempt reflog receipt is missing; inspect or abort, never blindly replay.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
if (operation !== undefined) {
|
|
138
|
+
await assertOperation(path, kind, commit, progress.head, branch, source);
|
|
139
|
+
}
|
|
140
|
+
const unresolved = await conflicts(path);
|
|
141
|
+
if (unresolved.length > 0) {
|
|
142
|
+
return pause(unresolved, kind);
|
|
143
|
+
}
|
|
144
|
+
if (!fresh && current.status !== "conflicted") {
|
|
145
|
+
throw new Error("Git operation is present but no paused conflict is recorded; inspect its execution before continuing.");
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
if (operation !== undefined) {
|
|
149
|
+
if (kind === "rebase") {
|
|
150
|
+
await command(path, ["rebase", "--continue"], action);
|
|
151
|
+
}
|
|
152
|
+
else if (kind === "merge") {
|
|
153
|
+
await command(path, ["commit", "--no-edit"], action);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const empty = await succeeds(path, ["diff", "--cached", "--quiet"]);
|
|
157
|
+
if (empty) {
|
|
158
|
+
save({ sourceProgress: { ...progress, emptyResolution: true } });
|
|
159
|
+
progress = current.sourceProgress;
|
|
160
|
+
}
|
|
161
|
+
await command(path, ["cherry-pick", empty ? "--skip" : "--continue"], action);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (kind === "rebase" && source.kind === "upstream") {
|
|
165
|
+
await command(path, ["rebase", "--onto", commit, source.taskBaseCommit, workspace.branch], action);
|
|
166
|
+
}
|
|
167
|
+
else if (kind === "merge") {
|
|
168
|
+
await command(path, ["merge", "--no-edit", "--no-ff", commit], action);
|
|
169
|
+
}
|
|
170
|
+
else if (kind === "ff") {
|
|
171
|
+
await command(path, ["merge", "--ff-only", commit], action);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// Choose from a read, not from a failed mutating merge: a Git error
|
|
175
|
+
// after a ref update is not proof that it is safe to cherry-pick.
|
|
176
|
+
const fastForward = await succeeds(path, ["merge-base", "--is-ancestor", progress.head, commit]);
|
|
177
|
+
await command(path, fastForward ? ["merge", "--ff-only", commit] : ["cherry-pick", commit], action);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const affectedPaths = await conflicts(path);
|
|
182
|
+
if (affectedPaths.length > 0)
|
|
183
|
+
return pause(affectedPaths, kind);
|
|
184
|
+
if (kind !== "cherry-pick" || await activeGitOperation(path) !== "cherry-pick"
|
|
185
|
+
|| !await succeeds(path, ["diff", "--cached", "--quiet"]))
|
|
186
|
+
throw error;
|
|
187
|
+
await assertOperation(path, kind, commit, progress.head, branch, source);
|
|
188
|
+
save({ sourceProgress: { ...progress, emptyResolution: true } });
|
|
189
|
+
progress = current.sourceProgress;
|
|
190
|
+
await command(path, ["cherry-pick", "--skip"], action);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await assertIntegrationCandidate(path);
|
|
194
|
+
const head = await line(path, "rev-parse", "HEAD");
|
|
195
|
+
save({ status: "running", conflict: undefined, sourceProgress: {
|
|
196
|
+
workspace: progress.workspace, branch, sourceDigest, completedSteps: index + 1, head
|
|
197
|
+
} });
|
|
198
|
+
progress = current.sourceProgress;
|
|
199
|
+
}
|
|
200
|
+
await assertIntegrationCandidate(path, progress.head);
|
|
201
|
+
save({ status: "running", conflict: undefined, candidateCommit: progress.head });
|
|
202
|
+
return current;
|
|
203
|
+
}
|
|
204
|
+
export function assertRecordedSourceCandidate(attempt, workspace) {
|
|
205
|
+
if (attempt.source.kind === "work-item" && attempt.source.strategy === "manual"
|
|
206
|
+
&& attempt.resolution?.action === "manual-resolution")
|
|
207
|
+
return;
|
|
208
|
+
const progress = attempt.sourceProgress;
|
|
209
|
+
const branch = workspace.branch.startsWith("refs/") ? workspace.branch : `refs/heads/${workspace.branch}`;
|
|
210
|
+
if (progress === undefined || progress.activeAction !== undefined
|
|
211
|
+
|| progress.head !== attempt.candidateCommit || progress.workspace !== resolve(workspace.path)
|
|
212
|
+
|| progress.branch !== branch || progress.sourceDigest !== integrationSourceDigest(attempt, workspace)) {
|
|
213
|
+
throw new Error("Integration candidate does not match its recorded source/workspace identity.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function integrationSourceDigest(attempt, workspace) {
|
|
217
|
+
return createHash("sha256").update(JSON.stringify([
|
|
218
|
+
attempt.taskId, attempt.id, attempt.projectId, attempt.targetRef, attempt.beforeCommit,
|
|
219
|
+
attempt.source, resolve(workspace.path), workspace.branch
|
|
220
|
+
])).digest("hex");
|
|
221
|
+
}
|
|
222
|
+
export async function assertIntegrationCandidate(path, expectedHead, expectedBranch) {
|
|
223
|
+
if (await activeGitOperation(path) !== undefined
|
|
224
|
+
|| (await command(path, ["status", "--porcelain=v1", "--untracked-files=all"])).trim() !== "") {
|
|
225
|
+
throw new Error("Integration candidate must be clean, with no unfinished Git operation.");
|
|
226
|
+
}
|
|
227
|
+
const head = await line(path, "rev-parse", "HEAD");
|
|
228
|
+
if (expectedHead !== undefined && head !== expectedHead) {
|
|
229
|
+
throw new Error(`Integration candidate moved to ${head}; expected ${expectedHead}.`);
|
|
230
|
+
}
|
|
231
|
+
if (expectedBranch !== undefined) {
|
|
232
|
+
const branch = expectedBranch.startsWith("refs/") ? expectedBranch : `refs/heads/${expectedBranch}`;
|
|
233
|
+
if ((await command(path, ["symbolic-ref", "HEAD"])).trim() !== branch) {
|
|
234
|
+
throw new Error("Integration candidate changed its owned branch.");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function assertOperation(path, kind, commit, before, branch, source) {
|
|
239
|
+
const active = await activeGitOperation(path);
|
|
240
|
+
if (active !== kind)
|
|
241
|
+
throw new Error(`Integration expected ${kind}, found ${active ?? "no active Git operation"}.`);
|
|
242
|
+
if (kind === "rebase") {
|
|
243
|
+
const directory = await gitPath(path, "rebase-merge");
|
|
244
|
+
// Yui invokes the merge backend explicitly. A legacy apply-backend
|
|
245
|
+
// operation cannot be silently treated as the same execution.
|
|
246
|
+
const [onto, original, headName] = await Promise.all(["onto", "orig-head", "head-name"].map(name => readFile(resolve(directory, name), "utf8")));
|
|
247
|
+
if (source.kind !== "upstream" || onto.trim() !== commit
|
|
248
|
+
|| original.trim() !== before || headName.trim() !== branch) {
|
|
249
|
+
throw new Error("Active rebase does not belong to this Integration source and target.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const activeSource = await line(path, "rev-parse", kind === "merge" ? "MERGE_HEAD" : "CHERRY_PICK_HEAD");
|
|
254
|
+
if (activeSource !== commit || await line(path, "rev-parse", "HEAD") !== before
|
|
255
|
+
|| (await command(path, ["symbolic-ref", "HEAD"])).trim() !== branch) {
|
|
256
|
+
throw new Error("Active Git operation does not belong to this Integration step.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function activeGitOperation(path) {
|
|
261
|
+
for (const dir of ["rebase-merge", "rebase-apply"]) {
|
|
262
|
+
try {
|
|
263
|
+
await readFile(resolve(await gitPath(path, dir), "head-name"));
|
|
264
|
+
return "rebase";
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error.code !== "ENOENT")
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (await succeeds(path, ["rev-parse", "--verify", "MERGE_HEAD"]))
|
|
272
|
+
return "merge";
|
|
273
|
+
if (await succeeds(path, ["rev-parse", "--verify", "CHERRY_PICK_HEAD"]))
|
|
274
|
+
return "cherry-pick";
|
|
275
|
+
// REBASE_HEAD is deliberately ignored: it can outlive a finished rebase.
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
async function hasExactRefReceipt(path, branch, before, action) {
|
|
279
|
+
if ((await command(path, ["symbolic-ref", "HEAD"])).trim() !== branch)
|
|
280
|
+
return false;
|
|
281
|
+
const head = await line(path, "rev-parse", "HEAD");
|
|
282
|
+
const log = await readFile(await gitPath(path, `logs/${branch}`), "utf8");
|
|
283
|
+
const last = log.trimEnd().split("\n").at(-1) ?? "";
|
|
284
|
+
const [transition, message] = last.split("\t");
|
|
285
|
+
const [oldHead, newHead] = (transition ?? "").split(" ");
|
|
286
|
+
return oldHead === before && newHead === head
|
|
287
|
+
&& (message === action || message?.startsWith(`${action}:`) === true
|
|
288
|
+
|| message?.startsWith(`${action} (finish):`) === true);
|
|
289
|
+
}
|
|
290
|
+
const actionId = () => `yui-integration-${randomBytes(16).toString("hex")}`;
|
|
291
|
+
async function gitPath(path, name) {
|
|
292
|
+
const value = (await command(path, ["rev-parse", "--git-path", name])).trim();
|
|
293
|
+
return isAbsolute(value) ? value : resolve(path, value);
|
|
294
|
+
}
|
|
295
|
+
async function conflicts(path) {
|
|
296
|
+
return (await command(path, ["diff", "--name-only", "--diff-filter=U"])).trim().split("\n").filter(Boolean);
|
|
297
|
+
}
|
|
298
|
+
async function line(path, ...args) {
|
|
299
|
+
return (await command(path, args)).trim();
|
|
300
|
+
}
|
|
301
|
+
async function succeeds(path, args, action) {
|
|
302
|
+
try {
|
|
303
|
+
await command(path, args, action);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function command(path, args, action) {
|
|
311
|
+
try {
|
|
312
|
+
return (await execute("git", [
|
|
313
|
+
"-C", path, "-c", "user.name=Yui", "-c", "user.email=yui@local",
|
|
314
|
+
"-c", "core.editor=true", "-c", "rebase.backend=merge", ...args
|
|
315
|
+
], {
|
|
316
|
+
encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 120_000,
|
|
317
|
+
env: { ...process.env, ...(action === undefined ? {} : { GIT_REFLOG_ACTION: action }) }
|
|
318
|
+
})).stdout;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
throw new Error(`Integration Git failed: ${error.stderr?.trim() ?? String(error)}`, { cause: error });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { markGlobalRoleMessageDelivered } from "./message.js";
|
|
2
|
+
export function recordGlobalSteerResult(store, roleName, messageId, receipt) {
|
|
3
|
+
store.transaction(tx => {
|
|
4
|
+
const message = tx.listGlobalRoleMessages(roleName).find(entry => entry.id === messageId);
|
|
5
|
+
const source = message?.inputControl ?? message?.interruptThen?.reusedInput;
|
|
6
|
+
if (message === undefined || source?.action !== "steer")
|
|
7
|
+
throw new Error("Global steer has no original Message.");
|
|
8
|
+
// Conclusive native evidence outranks a lost/late transport acknowledgement.
|
|
9
|
+
if (message.control?.outcome === "accepted" || message.control?.outcome === "rejected")
|
|
10
|
+
return;
|
|
11
|
+
const now = new Date();
|
|
12
|
+
const outcome = receipt.state === "steered" ? "accepted"
|
|
13
|
+
: receipt.state === "steer-unknown" ? "delivery-unknown" : "rejected";
|
|
14
|
+
const updated = { ...message, control: {
|
|
15
|
+
requestId: source.requestId, receiptId: `steer:${roleName}/${messageId}`,
|
|
16
|
+
outcome, observedAt: now.toISOString()
|
|
17
|
+
} };
|
|
18
|
+
tx.updateGlobalRoleMessage(outcome === "accepted" ? markGlobalRoleMessageDelivered(updated, now) : updated);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function recordGlobalInterruptResult(store, roleName, receiptId, receipt) {
|
|
22
|
+
store.transaction(tx => {
|
|
23
|
+
const sessions = tx.getGlobalRoleSessionSet(roleName);
|
|
24
|
+
const entry = Object.entries(sessions?.interrupts ?? {}).find(([, value]) => value.receiptId === receiptId);
|
|
25
|
+
if (sessions == null || entry === undefined)
|
|
26
|
+
throw new Error("Global interrupt has no original request.");
|
|
27
|
+
if (entry[1].receipt !== undefined)
|
|
28
|
+
return;
|
|
29
|
+
tx.saveGlobalRoleSessionSet({ ...sessions, interrupts: { ...sessions.interrupts,
|
|
30
|
+
[entry[0]]: { ...entry[1], receipt }
|
|
31
|
+
} });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turn statuses that mean a Turn record is present and not yet terminal. This is
|
|
4
|
+
* only "there is something here", not "it is safe to target": `submitting` is a
|
|
5
|
+
* call still in flight and `delivery-unknown` is an outbound whose acceptance was
|
|
6
|
+
* never proven. Both are separated from `accepted` below (decision-3 §3/§8) so an
|
|
7
|
+
* unconfirmed delivery is reported as DELIVERY_UNKNOWN, never silently steered or
|
|
8
|
+
* cancelled as if it were a clean active Turn.
|
|
9
|
+
*/
|
|
10
|
+
const PRESENT_TURN_STATUSES = new Set(["submitting", "accepted", "delivery-unknown"]);
|
|
11
|
+
/**
|
|
12
|
+
* The scope-generic core. It takes an already-fetched Session set and the owner
|
|
13
|
+
* decoration for a resolved target, so Task and Global callers share the exact
|
|
14
|
+
* same capability, present-Turn, authority, and target-match logic.
|
|
15
|
+
*
|
|
16
|
+
* @param decorate Adds the owner-specific fields to a resolved target. For a
|
|
17
|
+
* Task Role it attaches the taskId; for a Global Role it attaches nothing.
|
|
18
|
+
*/
|
|
19
|
+
function resolveInputControl(sessions, kind, expectedTarget, roleName) {
|
|
20
|
+
const active = sessions?.sessions[sessions.activeAgentId];
|
|
21
|
+
// Capability is read from the declared Driver of the current active Session,
|
|
22
|
+
// never hardcoded by Agent name (decision-3 §7). When there is no active
|
|
23
|
+
// Session at all there is also no current Turn to target.
|
|
24
|
+
if (sessions == null || active === undefined || active.status !== "active") {
|
|
25
|
+
return { outcome: "no-active-turn", code: "NO_ACTIVE_TURN",
|
|
26
|
+
detail: "No active native Session holds a current Turn for this Role." };
|
|
27
|
+
}
|
|
28
|
+
const capabilities = builtinAgentDriverRegistry().requireByAdapterId(active.adapterId).capabilities;
|
|
29
|
+
if (kind === "steer" && capabilities.input.steer !== "fenced") {
|
|
30
|
+
return { outcome: "unsupported", code: "STEER_UNSUPPORTED",
|
|
31
|
+
detail: `Agent plan '${active.adapterId}' cannot steer its current Turn; `
|
|
32
|
+
+ "the input is saved for an explicit interrupt --then-message or a queue." };
|
|
33
|
+
}
|
|
34
|
+
if (kind === "interrupt" && capabilities.control.interruptDelivery !== "native") {
|
|
35
|
+
return { outcome: "unsupported", code: "INTERRUPT_UNSUPPORTED",
|
|
36
|
+
detail: `Agent plan '${active.adapterId}' has no native interrupt; Yui will not `
|
|
37
|
+
+ "stop the owned process, kill, restart, or detach it." };
|
|
38
|
+
}
|
|
39
|
+
// A Global Role now carries the same optional providerBinding as a Task Role;
|
|
40
|
+
// both read it the same way. Its absence is "no current Turn", never an error.
|
|
41
|
+
const binding = "providerBinding" in sessions ? sessions.providerBinding ?? null : null;
|
|
42
|
+
const turn = binding?.run ?? null;
|
|
43
|
+
if (binding === null || turn === null || !PRESENT_TURN_STATUSES.has(turn.status)) {
|
|
44
|
+
return { outcome: "no-active-turn", code: "NO_ACTIVE_TURN",
|
|
45
|
+
detail: "The Provider is not running a Turn now; there is nothing to steer or interrupt." };
|
|
46
|
+
}
|
|
47
|
+
// A human writer fence (an interactive takeover) owns the Turn; the Controller
|
|
48
|
+
// must not steer or cancel across it.
|
|
49
|
+
if (binding.authority.owner !== "controller") {
|
|
50
|
+
return { outcome: "target-changed", code: "TARGET_CHANGED",
|
|
51
|
+
detail: `Provider authority is ${binding.authority.owner}-held; release the takeover before a managed control.` };
|
|
52
|
+
}
|
|
53
|
+
const observedId = turn.nativeTurnId ?? turn.attemptId;
|
|
54
|
+
if (expectedTarget !== observedId
|
|
55
|
+
&& !(turn.nativeTurnId !== undefined && expectedTarget === turn.attemptId)) {
|
|
56
|
+
return { outcome: "target-changed", code: "TARGET_CHANGED",
|
|
57
|
+
detail: `The current Turn is ${observedId}, not ${expectedTarget}; re-read the Session before retrying.` };
|
|
58
|
+
}
|
|
59
|
+
// The right Turn is confirmed; now report its own delivery state. decision-3
|
|
60
|
+
// §3/§8, message-3 #7: an original Turn still in flight (`submitting`) or whose
|
|
61
|
+
// acceptance was never proven (`delivery-unknown`) is not a safe target.
|
|
62
|
+
// Steering it would push a second input onto an unconfirmed one; cancelling it
|
|
63
|
+
// would guess a stop against an attempt that may not have landed. Report
|
|
64
|
+
// DELIVERY_UNKNOWN so the caller confirms the original attempt first — never a
|
|
65
|
+
// silent downgrade to another action (§10).
|
|
66
|
+
if (turn.status !== "accepted") {
|
|
67
|
+
return { outcome: "delivery-unknown", code: "DELIVERY_UNKNOWN",
|
|
68
|
+
detail: `The current Turn's own delivery is ${turn.status}; confirm the original attempt `
|
|
69
|
+
+ "before steering or interrupting. Do not reissue under a new requestId or a different action." };
|
|
70
|
+
}
|
|
71
|
+
if (kind === "steer" && turn.nativeTurnId === undefined) {
|
|
72
|
+
return { outcome: "delivery-unknown", code: "DELIVERY_UNKNOWN",
|
|
73
|
+
detail: "The Provider has not supplied an exact native Turn id for steering." };
|
|
74
|
+
}
|
|
75
|
+
return { outcome: "ready", target: {
|
|
76
|
+
roleName, agentId: active.agentId, adapterId: active.adapterId,
|
|
77
|
+
nativeSessionId: active.nativeSessionId,
|
|
78
|
+
...(turn.nativeTurnId === undefined ? {} : { nativeTurnId: turn.nativeTurnId }),
|
|
79
|
+
attemptId: turn.attemptId,
|
|
80
|
+
authority: { epoch: binding.authority.epoch, owner: "controller",
|
|
81
|
+
holderId: binding.authority.holderId ?? "controller" }
|
|
82
|
+
} };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a live steer/interrupt against a Task Role's current native Turn.
|
|
86
|
+
*
|
|
87
|
+
* @param expectedTarget The Leader's exact expectation for the current Turn,
|
|
88
|
+
* as observed (its nativeTurnId, or its attemptId when no native id exists).
|
|
89
|
+
* A steer/interrupt that no longer matches is TARGET_CHANGED, never retargeted.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveTaskInputControl(store, taskId, roleName, kind, expectedTarget) {
|
|
92
|
+
const resolution = resolveInputControl(store.getTaskRoleSessionSet(taskId, roleName), kind, expectedTarget, roleName);
|
|
93
|
+
// A Task target carries its taskId so the live edge addresses the exact Host.
|
|
94
|
+
return resolution.outcome === "ready"
|
|
95
|
+
? { outcome: "ready", target: { taskId, ...resolution.target } }
|
|
96
|
+
: resolution;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a live steer/interrupt against a Global Role's current native Turn
|
|
100
|
+
* (decision-3 §6/§9). Identical contract to the Task resolver, reading the
|
|
101
|
+
* Global Role's own Session set; the resolved target carries no taskId because a
|
|
102
|
+
* Global Role has no owning Task and never fabricates one.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveGlobalInputControl(store, roleName, kind, expectedTarget) {
|
|
105
|
+
return resolveInputControl(store.getGlobalRoleSessionSet(roleName), kind, expectedTarget, roleName);
|
|
106
|
+
}
|