@zq-silk/yui 0.15.9 → 0.15.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -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 !== "blocked" || attempt.conflict === undefined) {
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" : "blocked",
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 blocked Integration needs a ConflictReport.");
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
- ?? "Integration blocked without a conflict report.", now());
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
- ? "Integration failed before the target ref advanced."
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
+ }