@zq-silk/yui 0.12.4 → 0.13.1

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.
@@ -11,8 +11,7 @@ import { execSync } from "node:child_process";
11
11
  import { createHash } from "node:crypto";
12
12
  import { resolve, sep } from "node:path";
13
13
  import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
14
- import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
15
- import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
14
+ import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
16
15
  export function createDurableJobControl(store) {
17
16
  return {
18
17
  startJob(params, now) {
@@ -63,7 +62,7 @@ export function createDurableJobControl(store) {
63
62
  return null;
64
63
  // rr8: Bind the cancel request to the caller's managed identity. The
65
64
  // same rules as job.start apply, checked against the job's owner.
66
- assertCallerAuthorized(tx, caller, current.owner, taskId);
65
+ assertCallerAuthorized(tx, caller, taskId);
67
66
  const next = requestDurableJobCancel(current, now);
68
67
  if (next !== current)
69
68
  tx.saveDurableJob(taskId, next);
@@ -75,11 +74,7 @@ export function createDurableJobControl(store) {
75
74
  const current = tx.getDurableJob(taskId, jobId);
76
75
  if (current === null)
77
76
  return null;
78
- // Acknowledge is a Leader-only recovery decision. Reuse the shared
79
- // caller-key, active-Run, receipt, and Session checks, but explicitly
80
- // require the Leader role; start/cancel's owner binding intentionally
81
- // permits a Worker to operate its own Work Item.
82
- assertCallerAuthorized(tx, caller, current.owner, taskId, { leaderOnly: true });
77
+ assertCallerAuthorized(tx, caller, taskId);
83
78
  const next = acknowledgeUnknownDurableJob(current, now);
84
79
  if (next !== current)
85
80
  tx.saveDurableJob(taskId, next);
@@ -184,9 +179,9 @@ function validateStartParams(store, params) {
184
179
  }
185
180
  }
186
181
  // rr8: Bind the declared owner to the caller's managed identity. A
187
- // Reviewer can never start jobs; a Worker can only start jobs owned by
188
- // its own Work Item; a Leader or a plain user retains full access.
189
- assertCallerAuthorized(store, params.caller, params.owner, params.taskId);
182
+ // Role is not an authorization boundary. Scope and exact managed Session
183
+ // identity are verified independently below.
184
+ assertCallerAuthorized(store, params.caller, params.taskId);
190
185
  }
191
186
  /**
192
187
  * rr8/rr12: Bind the declared job owner to the caller's managed identity. The
@@ -194,49 +189,70 @@ function validateStartParams(store, params) {
194
189
  * against durable Run/Session state — a self-reported role or scope is never
195
190
  * authority on its own. The rules:
196
191
  *
197
- * - `user` (non-managed): full access, but only when the request carries a
198
- * `leaderAssertion` the Controller verifies against the active in-flight
199
- * Leader Run. A managed Session that sheds its identity and declares
200
- * `scope: "user"` without that proof is rejected.
192
+ * - `user` (non-managed): rejected because it has no managed Session identity.
193
+ * - `global`: full Task authority after current Role Session verification.
201
194
  * - `task` + mismatched taskId: rejected.
202
195
  * - `task` + missing `runId`: rejected (a managed caller must bind to a Run).
203
196
  * - `task` + Run not found / not active / `roleName !== role`: rejected — the
204
197
  * claimed Role must be the real Role of an active Run.
205
- * - `task` + Reviewer: always rejected.
206
- * - `task` + Leader: must carry `receiptId` and pass the same
207
- * active-Run + in-flight-receipt + Session check as `job.acknowledge`, so a
208
- * borrowed or stale Leader Run cannot authorize the request.
209
- * - `task` + any other Role (Worker etc.): the owner must be `work-item` and
210
- * the caller's Run must be for that same Work Item.
198
+ * - `task`: full authority inside the matching Task after active Run and
199
+ * per-Session caller-key verification; Role does not narrow it.
211
200
  */
212
201
  /**
213
202
  * rr13: `job.start`/`job.cancel` caller authorization at the Controller
214
203
  * boundary. Every identity claim the Controller can verify from durable state
215
- * (role, runId, receiptId, leaderAssertion) is replayable by any client in the
204
+ * (role and runId) is replayable by any client in the
216
205
  * same Home that reads state.json. The channel itself is therefore not
217
206
  * authenticated by those claims alone. A non-replayable per-Session caller key
218
207
  * closes the gap:
219
208
  *
220
209
  * - `user` scope: **rejected outright** for start/cancel. A bare shell or a
221
- * managed Session that sheds its identity has no channel binding. The human
222
- * operator acts through the Leader Session, which carries a caller key.
210
+ * managed Session that sheds its identity has no managed Session binding.
211
+ * - `global` scope: verified against the current global Role Session.
223
212
  * - `task` scope: the caller must present `callerKey` — the
224
213
  * `YUI_JOB_CALLER_KEY` injected at its native Session launch. The Controller
225
214
  * hashes it (SHA-256) and compares against the durable `jobCallerKeyHashes`
226
215
  * map for the caller's Role + Agent. No legacy fallback: an absent hash or a
227
216
  * mismatched key is UNAUTHORIZED.
228
217
  *
229
- * The existing Run/role/WorkItem binding checks (rr8/rr12) run after the key
230
- * check, so a forged identity that also lacks the key is rejected at the
231
- * channel boundary before any durable-state lookup.
218
+ * The existing Run binding checks run after the key check, so a forged
219
+ * identity that also lacks the key is rejected at the channel boundary.
232
220
  */
233
- function assertCallerAuthorized(store, caller, owner, taskId, options = {}) {
221
+ function assertCallerAuthorized(store, caller, taskId) {
234
222
  if (caller.scope === "user") {
235
223
  // rr13: A user-scope caller has no per-Session channel binding. Every
236
- // durable-state claim it could carry (leaderAssertion etc.) is replayable
224
+ // durable-state claim it could carry is replayable
237
225
  // by any client in the same Home. Reject outright (fail-closed).
238
226
  throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key; user scope is rejected.");
239
227
  }
228
+ if (caller.scope === "global") {
229
+ const roleName = caller.role;
230
+ const agentId = caller.agentId;
231
+ const role = roleName === undefined ? null : store.getGlobalRole(roleName);
232
+ const binding = role === null || agentId === undefined
233
+ ? undefined
234
+ : role.agentBindings[role.activeAgentId];
235
+ const sessions = roleName === undefined ? null : store.getGlobalRoleSessionSet(roleName);
236
+ const session = activeLiveRoleAgentSession(sessions);
237
+ if (role === null
238
+ || binding === undefined
239
+ || agentId === undefined
240
+ || caller.adapterId === undefined
241
+ || caller.launchId === undefined
242
+ || sessions === null
243
+ || sessions.activeAgentId !== role.activeAgentId
244
+ || session === null
245
+ || binding.agentId !== agentId
246
+ || binding.adapterId !== caller.adapterId
247
+ || session.agentId !== agentId
248
+ || session.adapterId !== caller.adapterId
249
+ || session.launchId !== caller.launchId
250
+ || (caller.nativeSessionId !== undefined
251
+ && session.nativeSessionId !== caller.nativeSessionId)) {
252
+ throw jobControlError("UNAUTHORIZED", "DurableJob control requires the current managed global Agent Session.");
253
+ }
254
+ return;
255
+ }
240
256
  if (caller.taskId !== taskId) {
241
257
  throw jobControlError("UNAUTHORIZED", "A managed Task Session may not start or cancel Jobs for a different Task.");
242
258
  }
@@ -262,29 +278,6 @@ function assertCallerAuthorized(store, caller, owner, taskId, options = {}) {
262
278
  if (presentedHash !== expectedHash) {
263
279
  throw jobControlError("UNAUTHORIZED", "The managed Session caller key does not match the durable hash.");
264
280
  }
265
- if (options.leaderOnly && caller.role !== "leader") {
266
- throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current Task Leader Session.");
267
- }
268
- if (caller.role === "reviewer") {
269
- throw jobControlError("UNAUTHORIZED", "A Reviewer may not start or cancel DurableJobs.");
270
- }
271
- if (caller.role === "leader") {
272
- // rr12: Verify the caller is THE current in-flight Task Leader, not a
273
- // borrowed or stale Leader Run. This mirrors job.acknowledge.
274
- if (caller.receiptId === undefined) {
275
- throw jobControlError("UNAUTHORIZED", "A Leader must carry the current in-flight Turn receipt.");
276
- }
277
- assertLeaderActionRun(store, taskId, {
278
- runId: run.id,
279
- receiptId: caller.receiptId
280
- });
281
- return;
282
- }
283
- // Worker (or any non-leader, non-reviewer managed role): the owner must be
284
- // the caller's own Work Item, verified through the caller's Run.
285
- if (owner.kind !== "work-item" || run.workItemId !== owner.workItemId) {
286
- throw jobControlError("UNAUTHORIZED", "A managed Worker may only start or cancel Jobs owned by its own Work Item.");
287
- }
288
281
  }
289
282
  /**
290
283
  * Resolve the managed workspace for the job's owner. Returns null if no
@@ -331,30 +324,6 @@ function readGitHead(path) {
331
324
  function isTerminalWorkItemStatus(status) {
332
325
  return status === "completed" || status === "failed" || status === "retired";
333
326
  }
334
- /**
335
- * rr5/f5: Verify that the caller is the current in-flight Task Leader. The
336
- * runId must identify the active Leader Run and the receiptId must match the
337
- * Run's current in-flight Turn receipt. This binds the assertion to the
338
- * Leader identity/session the Controller already tracks — a bare flag or a
339
- * stale Run/receipt pair is rejected.
340
- */
341
- function assertLeaderActionRun(store, taskId, assertion) {
342
- const run = store.getActiveAgentRun(taskId, "leader");
343
- if (run === null || run.status !== "active" || run.id !== assertion.runId) {
344
- throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current active Task Leader Run.");
345
- }
346
- const expectedReceipt = formatAgentRunReceiptId(taskId, run.id);
347
- if (assertion.receiptId !== expectedReceipt) {
348
- throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader receipt does not match the current Run.");
349
- }
350
- const sessions = store.getTaskRoleSessionSet(taskId, "leader");
351
- if (sessions === null
352
- || sessions.inFlight === null
353
- || sessions.inFlight.runId !== run.id
354
- || sessions.inFlight.receiptId !== agentRunDeliveryReceiptId(run)) {
355
- throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader Run is not in flight.");
356
- }
357
- }
358
327
  /**
359
328
  * Strict `job.start` params parsing. Throws CoreApplicationError-shaped errors
360
329
  * so the socket layer reports INVALID_PARAMS without leaking internals.
@@ -430,8 +399,7 @@ export function parseDurableJobCancelParams(value) {
430
399
  }
431
400
  /**
432
401
  * rr26: `job.acknowledge` carries the same managed task caller as
433
- * job.start/job.cancel. A replayable leaderAssertion without the ephemeral
434
- * Session caller key is rejected by assertCallerAuthorized.
402
+ * job.start/job.cancel.
435
403
  */
436
404
  export function parseDurableJobAcknowledgeParams(value) {
437
405
  if (typeof value !== "object" || value === null || Array.isArray(value)
@@ -508,9 +476,8 @@ function parseSteps(value) {
508
476
  * boundary (fail-closed). A non-managed caller must explicitly resolve to
509
477
  * `{scope: "user"}`; the Controller never defaults a missing identity to
510
478
  * user scope. A present caller must carry a valid `scope` and may carry
511
- * optional `taskId`, `role`, `runId`, `receiptId`, and `leaderAssertion`
512
- * fields. The identity is verified against durable Run state by
513
- * `assertCallerAuthorized`; parsing only validates the JSON shape.
479
+ * optional managed Session identity fields. The identity is verified against
480
+ * durable state by `assertCallerAuthorized`; parsing only validates the JSON shape.
514
481
  */
515
482
  function parseCaller(value) {
516
483
  if (value === undefined) {
@@ -521,14 +488,15 @@ function parseCaller(value) {
521
488
  }
522
489
  const record = value;
523
490
  const allowed = new Set([
524
- "scope", "taskId", "role", "runId", "receiptId", "leaderAssertion", "callerKey"
491
+ "scope", "taskId", "role", "agentId", "adapterId", "launchId", "nativeSessionId",
492
+ "runId", "callerKey"
525
493
  ]);
526
494
  for (const key of Object.keys(record)) {
527
495
  if (!allowed.has(key)) {
528
496
  throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
529
497
  }
530
498
  }
531
- if (record.scope !== "user" && record.scope !== "task") {
499
+ if (record.scope !== "user" && record.scope !== "global" && record.scope !== "task") {
532
500
  throw jobControlError("INVALID_PARAMS", "job caller scope is invalid.");
533
501
  }
534
502
  const optionalId = (key) => {
@@ -539,9 +507,11 @@ function parseCaller(value) {
539
507
  };
540
508
  const taskId = optionalId("taskId");
541
509
  const role = optionalId("role");
510
+ const agentId = optionalId("agentId");
511
+ const adapterId = optionalId("adapterId");
512
+ const launchId = optionalId("launchId");
513
+ const nativeSessionId = optionalId("nativeSessionId");
542
514
  const runId = optionalId("runId");
543
- const receiptId = optionalId("receiptId");
544
- const leaderAssertion = parseLeaderAssertion(record.leaderAssertion);
545
515
  const callerKey = record.callerKey === undefined
546
516
  ? undefined
547
517
  : requiredId(record.callerKey, "job caller callerKey");
@@ -549,30 +519,14 @@ function parseCaller(value) {
549
519
  scope: record.scope,
550
520
  ...(taskId === undefined ? {} : { taskId }),
551
521
  ...(role === undefined ? {} : { role }),
522
+ ...(agentId === undefined ? {} : { agentId }),
523
+ ...(adapterId === undefined ? {} : { adapterId }),
524
+ ...(launchId === undefined ? {} : { launchId }),
525
+ ...(nativeSessionId === undefined ? {} : { nativeSessionId }),
552
526
  ...(runId === undefined ? {} : { runId }),
553
- ...(receiptId === undefined ? {} : { receiptId }),
554
- ...(leaderAssertion === undefined ? {} : { leaderAssertion }),
555
527
  ...(callerKey === undefined ? {} : { callerKey })
556
528
  };
557
529
  }
558
- /**
559
- * rr12: Parse a `leaderAssertion` sub-object (`{runId, receiptId}`) carried by
560
- * a user-scope caller. Returns undefined when absent; throws on a malformed
561
- * shape.
562
- */
563
- function parseLeaderAssertion(value) {
564
- if (value === undefined)
565
- return undefined;
566
- if (typeof value !== "object" || value === null || Array.isArray(value)
567
- || Object.keys(value).length !== 2) {
568
- throw jobControlError("INVALID_PARAMS", "job caller leaderAssertion must have runId and receiptId.");
569
- }
570
- const record = value;
571
- return {
572
- runId: requiredId(record.runId, "job caller leaderAssertion runId"),
573
- receiptId: requiredId(record.receiptId, "job caller leaderAssertion receiptId")
574
- };
575
- }
576
530
  function parseStringMap(value, label) {
577
531
  if (value === undefined)
578
532
  return {};
@@ -16,7 +16,7 @@ import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenance
16
16
  import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
17
17
  import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
18
18
  import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
19
- import { recordIntegrationCheckJob, requireLeaderDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
19
+ import { recordIntegrationCheckJob, requireResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
20
20
  import { createManagedWorkspace } from "../worktree/managedWorkspace.js";
21
21
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
22
22
  import { readRuntimeIdentity } from "../release/runtimeRelease.js";
@@ -228,7 +228,7 @@ export class GitIntegrationService {
228
228
  }
229
229
  catch (error) {
230
230
  if (error instanceof RemoteBaselineConflictError) {
231
- const pending = requireLeaderDecision(current, {
231
+ const pending = requireResolutionDecision(current, {
232
232
  affectedPaths: error.affectedPaths,
233
233
  summary: error.message
234
234
  }, this.now());
@@ -592,7 +592,7 @@ export class GitIntegrationService {
592
592
  await git(["-C", candidatePath, "cherry-pick", "--skip"]);
593
593
  continue;
594
594
  }
595
- const pending = requireLeaderDecision(attempt, {
595
+ const pending = requireResolutionDecision(attempt, {
596
596
  affectedPaths,
597
597
  summary: `ChangeSet ${changeSetId} conflicts with ${attempt.targetRef}.`
598
598
  }, this.now());
@@ -4,7 +4,7 @@ import { normalizeCheckResult } from "./checkResult.js";
4
4
  export function createIntegrationAttempt(input, now) {
5
5
  const timestamp = now.toISOString();
6
6
  return validateIntegrationAttempt({
7
- schemaVersion: 4,
7
+ schemaVersion: 5,
8
8
  id: input.id,
9
9
  taskId: input.taskId,
10
10
  projectId: input.projectId,
@@ -37,7 +37,7 @@ export function recordIntegrationCheckJob(attempt, jobId, now) {
37
37
  updatedAt: now.toISOString()
38
38
  });
39
39
  }
40
- export function requireLeaderDecision(attempt, report, now) {
40
+ export function requireResolutionDecision(attempt, report, now) {
41
41
  validateIntegrationAttempt(attempt);
42
42
  const continuingAfterResolution = attempt.status === "blocked"
43
43
  && attempt.resolution?.action === "manual-resolution";
@@ -52,7 +52,7 @@ export function requireLeaderDecision(attempt, report, now) {
52
52
  updatedAt: now.toISOString()
53
53
  });
54
54
  }
55
- export function recordResolutionDecision(attempt, decision, now) {
55
+ export function recordResolutionDecision(attempt, decision, decidedBy, now) {
56
56
  validateIntegrationAttempt(attempt);
57
57
  if (attempt.status !== "blocked" || attempt.conflict === undefined) {
58
58
  throw new Error("Integration has no pending semantic decision.");
@@ -67,13 +67,19 @@ export function recordResolutionDecision(attempt, decision, now) {
67
67
  resolution: {
68
68
  action: decision.action,
69
69
  rationale: requireText(decision.rationale, "Resolution rationale"),
70
- decidedBy: "leader",
70
+ decidedBy: requireTaskControlActor(decidedBy),
71
71
  decidedAt: timestamp
72
72
  },
73
73
  updatedAt: timestamp,
74
74
  ...(decision.action === "reject" ? { endedAt: timestamp } : {})
75
75
  });
76
76
  }
77
+ function requireTaskControlActor(value) {
78
+ if (value !== "user" && value !== "operator" && value !== "leader") {
79
+ throw new Error(`Integration decision actor is invalid: ${String(value)}.`);
80
+ }
81
+ return value;
82
+ }
77
83
  const TERMINAL_STATUSES = ["committed", "superseded", "failed"];
78
84
  export function updateIntegrationAttempt(attempt, patch, now) {
79
85
  validateIntegrationAttempt(attempt);
@@ -117,8 +123,8 @@ export function supersedeIntegration(attempt, reason, now) {
117
123
  });
118
124
  }
119
125
  export function validateIntegrationAttempt(attempt) {
120
- if (attempt.schemaVersion !== 4) {
121
- throw new Error("IntegrationAttempt must use schemaVersion 4.");
126
+ if (attempt.schemaVersion !== 5) {
127
+ throw new Error("IntegrationAttempt must use schemaVersion 5.");
122
128
  }
123
129
  validateTaskRecordReference({
124
130
  taskId: attempt.taskId,
@@ -162,9 +168,7 @@ export function validateIntegrationAttempt(attempt) {
162
168
  throw new Error(`Resolution action is invalid: ${String(attempt.resolution.action)}.`);
163
169
  }
164
170
  requireText(attempt.resolution.rationale, "Resolution rationale");
165
- if (attempt.resolution.decidedBy !== "leader") {
166
- throw new Error("Only the Leader may record a ResolutionDecision.");
167
- }
171
+ requireTaskControlActor(attempt.resolution.decidedBy);
168
172
  requireTimestamp(attempt.resolution.decidedAt, "Resolution decidedAt");
169
173
  }
170
174
  attempt.checks?.forEach(normalizeCheckResult);
@@ -701,11 +701,11 @@ export async function reconcileIntegrationQueueEntry(store, taskId, entryId, git
701
701
  }
702
702
  /**
703
703
  * A conflicted entry may carry a blocked IntegrationAttempt waiting for a
704
- * leader decision. Requeue or supersede IS that decision — the blocked
704
+ * Task Agent decision. Requeue or supersede IS that decision — the blocked
705
705
  * attempt is abandoned — so resolve it as rejected (terminal `failed`)
706
706
  * rather than leaving it to block Task retirement.
707
707
  */
708
- function resolveBlockedAttempt(store, taskId, entry, decision, now) {
708
+ function resolveBlockedAttempt(store, taskId, entry, decision, decidedBy, now) {
709
709
  if (entry.integrationAttemptId === undefined)
710
710
  return;
711
711
  const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
@@ -714,7 +714,7 @@ function resolveBlockedAttempt(store, taskId, entry, decision, now) {
714
714
  const rejected = recordResolutionDecision(attempt, {
715
715
  action: "reject",
716
716
  rationale: `Integration Attempt rejected by queue ${decision}.`
717
- }, now());
717
+ }, decidedBy, now());
718
718
  store.saveIntegrationAttempt(taskId, rejected);
719
719
  }
720
720
  /**
@@ -737,7 +737,7 @@ function assertRecoverableAttempt(store, taskId, entry, action) {
737
737
  }
738
738
  }
739
739
  /** Retry a conflicted item (for example after a gate failure was fixed). */
740
- export function requeueIntegrationQueueEntry(store, taskId, entryId, now = () => new Date()) {
740
+ export function requeueIntegrationQueueEntry(store, taskId, entryId, decidedBy, now = () => new Date()) {
741
741
  // The committed-Attempt guard, the blocked-Attempt finalization, and the
742
742
  // queue write must be atomic: a concurrent manual-resolution continue can
743
743
  // commit the Attempt between the guard and the write, leaving the queue
@@ -749,20 +749,20 @@ export function requeueIntegrationQueueEntry(store, taskId, entryId, now = () =>
749
749
  throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
750
750
  }
751
751
  assertRecoverableAttempt(tx, taskId, entry, "requeue");
752
- resolveBlockedAttempt(tx, taskId, entry, "requeue", now);
752
+ resolveBlockedAttempt(tx, taskId, entry, "requeue", decidedBy, now);
753
753
  const waiting = markIntegrationQueueRequeued(entry, now());
754
754
  tx.saveIntegrationQueueEntry(taskId, waiting);
755
755
  return waiting;
756
756
  });
757
757
  }
758
- export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, now = () => new Date()) {
758
+ export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, decidedBy, now = () => new Date()) {
759
759
  return store.transaction((tx) => {
760
760
  const entry = tx.getIntegrationQueueEntry(taskId, entryId);
761
761
  if (entry === null) {
762
762
  throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
763
763
  }
764
764
  assertRecoverableAttempt(tx, taskId, entry, "supersede");
765
- resolveBlockedAttempt(tx, taskId, entry, "supersede", now);
765
+ resolveBlockedAttempt(tx, taskId, entry, "supersede", decidedBy, now);
766
766
  const superseded = markIntegrationQueueSuperseded(entry, reason, now());
767
767
  tx.saveIntegrationQueueEntry(taskId, superseded);
768
768
  return superseded;
@@ -1,15 +1,21 @@
1
1
  import { validateTaskRecordReference } from "../task/taskRecordReference.js";
2
- export function createMilestone(id, taskId, title, summary, now) {
2
+ export function createMilestone(id, taskId, title, summary, createdBy, now) {
3
3
  return {
4
- schemaVersion: 1,
4
+ schemaVersion: 2,
5
5
  id: validateTaskRecordReference({ taskId, localId: id }, "milestone").localId,
6
6
  taskId: requireSafeIdentity(taskId, "Task id"),
7
7
  title: requireText(title, "Milestone title"),
8
8
  summary: requireText(summary, "Milestone summary"),
9
- createdBy: "leader",
9
+ createdBy: requireTaskControlActor(createdBy),
10
10
  createdAt: now.toISOString()
11
11
  };
12
12
  }
13
+ function requireTaskControlActor(value) {
14
+ if (value !== "user" && value !== "operator" && value !== "leader") {
15
+ throw new Error(`Milestone createdBy is invalid: ${String(value)}.`);
16
+ }
17
+ return value;
18
+ }
13
19
  function requireSafeIdentity(value, label) {
14
20
  const normalized = requireText(value, label);
15
21
  if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
@@ -127,7 +127,7 @@ export function buildDeltaRecheckDispatchContext(input) {
127
127
  "- finding: the diff introduces a material problem; report it as a finding.",
128
128
  "- requires-full-review: you cannot prove equivalence (uncertainty, cross-scope,",
129
129
  " semantic change, evidence doubt). This is the safe default.",
130
- "Yui verified only the technical delta boundary; the Leader selected this mode.",
130
+ "Yui verified only the technical delta boundary; a Task-control Agent selected this mode.",
131
131
  `Previous accepted ReviewRound: ${previousRound.id}@${record.previousBaseCommit}`,
132
132
  `Previous acceptance summary: ${compact(previousRound.summary ?? previousRound.report ?? "")}`,
133
133
  ...(round.taskCandidate?.projects.map((project) => {
@@ -6,8 +6,8 @@ export const BLOCKING_FINDING_DISPOSITIONS = [
6
6
  "open",
7
7
  "fixed-pending-review"
8
8
  ];
9
- /** Dispositions the Leader may set explicitly. */
10
- export const LEADER_FINDING_DISPOSITIONS = [
9
+ /** Dispositions a Task-control Agent may set explicitly. */
10
+ export const TASK_CONTROL_FINDING_DISPOSITIONS = [
11
11
  "fixed-pending-review",
12
12
  "verified-fixed",
13
13
  "accepted-risk",
@@ -86,7 +86,7 @@ export function createReviewFinding(id, taskId, input, now) {
86
86
  */
87
87
  export function disposeReviewFinding(finding, input) {
88
88
  validateReviewFinding(finding);
89
- if (!LEADER_FINDING_DISPOSITIONS.includes(input.disposition)) {
89
+ if (!TASK_CONTROL_FINDING_DISPOSITIONS.includes(input.disposition)) {
90
90
  throw new Error(`ReviewFinding disposition is not a Leader decision: ${input.disposition}.`);
91
91
  }
92
92
  if (finding.disposition !== "open" && finding.disposition !== "fixed-pending-review") {
@@ -5,7 +5,7 @@ import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
5
5
  import { assertExecutionGroupTransition, resetReviewExecutionLane, validateExecutionGroup } from "../execution/executionGroup.js";
6
6
  export function createReviewRound(id, taskId, workItemId, candidateId, reviewerRoleName, requestedBy, reviewBaseCommit, now, executionGroup) {
7
7
  return validateReviewRound({
8
- schemaVersion: 5,
8
+ schemaVersion: 6,
9
9
  id: requireIdentity(id, "ReviewRound id"),
10
10
  taskId: requireIdentity(taskId, "Task id"),
11
11
  workItemId: requireIdentity(workItemId, "Work Item id"),
@@ -21,7 +21,7 @@ export function createReviewRound(id, taskId, workItemId, candidateId, reviewerR
21
21
  export function createTaskReviewRound(id, taskId, reviewerRoleName, requestedBy, taskCandidate, now, taskFinalReviewContract, executionGroup) {
22
22
  const candidate = validateTaskReviewCandidate(taskCandidate);
23
23
  return validateReviewRound({
24
- schemaVersion: 5,
24
+ schemaVersion: 6,
25
25
  id: requireIdentity(id, "ReviewRound id"),
26
26
  taskId: requireIdentity(taskId, "Task id"),
27
27
  reviewerRoleName: requireIdentity(reviewerRoleName, "Reviewer Role"),
@@ -47,7 +47,7 @@ export function createTaskReviewRound(id, taskId, reviewerRoleName, requestedBy,
47
47
  export function createTaskDeltaReviewRound(id, taskId, reviewerRoleName, requestedBy, taskCandidate, deltaRecheck, now, taskFinalReviewContract, executionGroup) {
48
48
  const candidate = validateTaskReviewCandidate(taskCandidate);
49
49
  return validateReviewRound({
50
- schemaVersion: 5,
50
+ schemaVersion: 6,
51
51
  id: requireIdentity(id, "ReviewRound id"),
52
52
  taskId: requireIdentity(taskId, "Task id"),
53
53
  reviewerRoleName: requireIdentity(reviewerRoleName, "Reviewer Role"),
@@ -138,7 +138,7 @@ export function finishReviewRound(round, status, summary, now, result = {}) {
138
138
  * returns to pending so infrastructure retries do not manufacture a new
139
139
  * semantic ReviewRound or duplicate findings.
140
140
  */
141
- export function retryTaskReviewRound(round, executionLaneId) {
141
+ export function retryTaskReviewRound(round, requestedBy, executionLaneId) {
142
142
  validateReviewRound(round);
143
143
  if ((round.scope ?? "work-item") !== "task") {
144
144
  throw new Error(`Only a Task-final ReviewRound can be retried in place: ${round.id}.`);
@@ -180,7 +180,7 @@ export function retryTaskReviewRound(round, executionLaneId) {
180
180
  // Keep the historical attempt Group and Lane addressable from AgentRun
181
181
  // history while resetting the Lane for another dispatch attempt.
182
182
  ...(retryExecutionGroup === undefined ? {} : { executionGroup: retryExecutionGroup }),
183
- requestedBy: "leader",
183
+ requestedBy: validateReviewRequestSource(requestedBy),
184
184
  status: "pending",
185
185
  ...(round.workspace === undefined ? {} : { workspace: round.workspace }),
186
186
  createdAt: round.createdAt
@@ -359,8 +359,8 @@ export function updateReviewExecutionGroup(round, executionGroup) {
359
359
  return validateReviewRound({ ...round, executionGroup });
360
360
  }
361
361
  export function validateReviewRound(round) {
362
- if (round.schemaVersion !== 5)
363
- throw new Error("ReviewRound must use schemaVersion 5.");
362
+ if (round.schemaVersion !== 6)
363
+ throw new Error("ReviewRound must use schemaVersion 6.");
364
364
  validateTaskRecordReference({ taskId: round.taskId, localId: round.id }, "reviewRound");
365
365
  requireIdentity(round.reviewerRoleName, "Reviewer Role");
366
366
  requireCommit(round.reviewBaseCommit, "Review base commit");
@@ -637,7 +637,10 @@ function requireCommit(value, label) {
637
637
  return commit;
638
638
  }
639
639
  function validateReviewRequestSource(source) {
640
- if (source !== "policy" && source !== "leader") {
640
+ if (source !== "policy"
641
+ && source !== "user"
642
+ && source !== "operator"
643
+ && source !== "leader") {
641
644
  throw new Error(`Review request source is invalid: ${String(source)}.`);
642
645
  }
643
646
  return source;
@@ -57,6 +57,8 @@ const REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION = 3;
57
57
  const REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION = 4;
58
58
  const REVIEW_ROUND_TASK_ANCHOR_FROM_VERSION = 4;
59
59
  const REVIEW_ROUND_TASK_ANCHOR_TO_VERSION = 5;
60
+ const REVIEW_ROUND_ACTOR_FROM_VERSION = 5;
61
+ const REVIEW_ROUND_ACTOR_TO_VERSION = 6;
60
62
  const ACTIVE_RUN_POINTER_FROM_VERSION = 1;
61
63
  const ACTIVE_RUN_POINTER_TO_VERSION = 2;
62
64
  const ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION = 2;
@@ -69,6 +71,10 @@ const INTEGRATION_ATTEMPT_FROM_VERSION = 2;
69
71
  const INTEGRATION_ATTEMPT_TO_VERSION = 3;
70
72
  const INTEGRATION_ATTEMPT_GATE_IDENTITY_FROM_VERSION = 3;
71
73
  const INTEGRATION_ATTEMPT_GATE_IDENTITY_TO_VERSION = 4;
74
+ const INTEGRATION_ATTEMPT_ACTOR_FROM_VERSION = 4;
75
+ const INTEGRATION_ATTEMPT_ACTOR_TO_VERSION = 5;
76
+ const MILESTONE_ACTOR_FROM_VERSION = 1;
77
+ const MILESTONE_ACTOR_TO_VERSION = 2;
72
78
  const INTEGRATION_QUEUE_FROM_VERSION = 0;
73
79
  const INTEGRATION_QUEUE_TO_VERSION = 1;
74
80
  const CONTEXT_SNAPSHOT_FROM_VERSION = 0;
@@ -174,12 +180,15 @@ export function createProductionStorageRegistry() {
174
180
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_FROM_VERSION, REVIEW_ROUND_TO_VERSION, "reviewRounds"))
175
181
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION, REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION, "reviewRounds"))
176
182
  .registerOfflineMigration(reviewRoundTaskAnchorStep())
183
+ .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_ACTOR_FROM_VERSION, REVIEW_ROUND_ACTOR_TO_VERSION, "reviewRounds"))
177
184
  .registerOfflineMigration(recordFamilyStep("activeRunPointer", ACTIVE_RUN_POINTER_FROM_VERSION, ACTIVE_RUN_POINTER_TO_VERSION, "activeRuns"))
178
185
  .registerOfflineMigration(managedWorkspaceFamilyStep())
179
186
  .registerOfflineMigration(activeRunPointerNamespaceStep())
180
187
  .registerOfflineMigration(changeSetManifestStep())
181
188
  .registerOfflineMigration(integrationAttemptSupersededStep())
182
189
  .registerOfflineMigration(recordFamilyStep("integrationAttempt", INTEGRATION_ATTEMPT_GATE_IDENTITY_FROM_VERSION, INTEGRATION_ATTEMPT_GATE_IDENTITY_TO_VERSION, "integrationAttempts"))
190
+ .registerOfflineMigration(recordFamilyStep("integrationAttempt", INTEGRATION_ATTEMPT_ACTOR_FROM_VERSION, INTEGRATION_ATTEMPT_ACTOR_TO_VERSION, "integrationAttempts"))
191
+ .registerOfflineMigration(recordFamilyStep("milestone", MILESTONE_ACTOR_FROM_VERSION, MILESTONE_ACTOR_TO_VERSION, "milestones"))
183
192
  .registerOfflineMigration(integrationQueueIntroductionStep())
184
193
  .registerOfflineMigration(contextSnapshotIntroductionStep())
185
194
  .registerCompatible(storedTaskDurableJobsStep())
@@ -67,13 +67,13 @@ export const CURRENT_CONTEXT_SNAPSHOT_SCHEMA_VERSION = 1;
67
67
  export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
68
68
  export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
69
69
  export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 12;
70
- export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 5;
70
+ export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 6;
71
71
  export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 3;
72
- export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 4;
72
+ export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 5;
73
73
  export const CURRENT_MESSAGE_SCHEMA_VERSION = 3;
74
74
  export const CURRENT_INPUT_REQUEST_SCHEMA_VERSION = 2;
75
75
  export const CURRENT_DECISION_SCHEMA_VERSION = 1;
76
- export const CURRENT_MILESTONE_SCHEMA_VERSION = 1;
76
+ export const CURRENT_MILESTONE_SCHEMA_VERSION = 2;
77
77
  export const CURRENT_EVENT_SCHEMA_VERSION = 2;
78
78
  export const CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION = 1;
79
79
  export const CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION = 1;
@@ -2621,8 +2621,10 @@ function storedMilestone(value) {
2621
2621
  validateTaskRecordReference({ taskId: milestone.taskId, localId: milestone.id }, "milestone");
2622
2622
  requireNormalizedText(milestone.title, "Milestone title");
2623
2623
  requireNormalizedText(milestone.summary, "Milestone summary");
2624
- if (milestone.createdBy !== "leader") {
2625
- throw new StorageRecordError("Milestone createdBy must be leader.");
2624
+ if (milestone.createdBy !== "user"
2625
+ && milestone.createdBy !== "operator"
2626
+ && milestone.createdBy !== "leader") {
2627
+ throw new StorageRecordError("Milestone createdBy is invalid.");
2626
2628
  }
2627
2629
  requireTimestamp(milestone.createdAt, "Milestone createdAt");
2628
2630
  return milestone;
@@ -3950,9 +3952,8 @@ function validReviewRoundTransition(existing, candidate) {
3950
3952
  || !isDeepStrictEqual(existing.taskCandidate, candidate.taskCandidate)
3951
3953
  || !sameTaskFinalReviewContract(existing.taskFinalReviewContract, candidate.taskFinalReviewContract)
3952
3954
  || !compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup)
3953
- // Issue 06: a Leader retry resets a failed Task-final Round to pending;
3954
- // the retry is itself a Leader request, so requestedBy may change from
3955
- // the original policy/contract value to "leader".
3955
+ // Issue 06: an explicit Task-control retry resets a failed Task-final
3956
+ // Round to pending, so requestedBy may change from the original actor.
3956
3957
  || (existing.requestedBy !== candidate.requestedBy
3957
3958
  && !(existing.status === "failed"
3958
3959
  && candidate.status === "pending"