@zq-silk/yui 0.15.3 → 0.15.6

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 (41) hide show
  1. package/dist/agent/managedRuntimeEnvironment.js +3 -0
  2. package/dist/cli.js +35 -127
  3. package/dist/commands/executionAuditCommands.js +6 -0
  4. package/dist/commands/taskContextCommand.js +4 -2
  5. package/dist/context/sessionBootstrapManifest.js +12 -21
  6. package/dist/controller/clientRuntime.js +1 -1
  7. package/dist/controller/fileSchedulerStoreAdapter.js +270 -162
  8. package/dist/controller/runtimeEventInbox.js +8 -0
  9. package/dist/controller/runtimeEventProcessor.js +31 -4
  10. package/dist/controller/runtimeHookTurnFence.js +101 -62
  11. package/dist/controller/runtimeLaunchCoordinator.js +38 -12
  12. package/dist/controller/runtimeObservationHook.js +17 -1
  13. package/dist/controller/structuredProviderObservation.js +39 -27
  14. package/dist/core/controllerClient.js +5 -0
  15. package/dist/core/controllerServer.js +7 -4
  16. package/dist/domain/agentResultTransport.js +2 -2
  17. package/dist/executor/agentExecutor.js +22 -38
  18. package/dist/executor/executorRegistry.js +16 -5
  19. package/dist/executor/fileRoleLaunchPlanner.js +22 -28
  20. package/dist/lifecycle/exactTurnTerminalization.js +3 -3
  21. package/dist/observability/executionAudit.js +12 -0
  22. package/dist/repository/executionLaneGitSnapshot.js +4 -3
  23. package/dist/repository/taskWorkspacePreparer.js +12 -4
  24. package/dist/review/taskFinalReviewContract.js +13 -32
  25. package/dist/runtime/agentError.js +299 -12
  26. package/dist/runtime/agentHost.js +419 -41
  27. package/dist/runtime/builtinAgentDrivers.js +5 -0
  28. package/dist/runtime/index.js +1 -1
  29. package/dist/runtime/ports.js +16 -2
  30. package/dist/runtime/providerRuntimeIdentity.js +34 -28
  31. package/dist/runtime/runtimeCoherence.js +91 -0
  32. package/dist/runtime/runtimeObservation.js +8 -5
  33. package/dist/runtime/structuredProviderHost.js +53 -44
  34. package/dist/runtime/tmuxAdapters.js +72 -43
  35. package/dist/scheduler/activeRoleTurnDelivery.js +59 -11
  36. package/dist/scheduler/leaderWakeupProcessor.js +61 -7
  37. package/dist/storage/sqliteSchema.js +9 -0
  38. package/dist/storage/storageVersions.js +1 -1
  39. package/dist/turn/turn.js +7 -1
  40. package/package.json +1 -1
  41. package/dist/runtime/exactControlPlane.js +0 -232
@@ -14,6 +14,7 @@ import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
14
14
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
15
15
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
16
16
  import { CommandExecutionError } from "../tmux/commandExecutor.js";
17
+ import { redactAgentErrorText } from "../runtime/agentError.js";
17
18
  import { detectRunningRelease, readActiveReleasePointer, readHandoverFence, verifyReleaseIntegrity, writeHandoverFence, writeRuntimeIdentity } from "../release/runtimeRelease.js";
18
19
  import { isBuiltinControllerMethod, ControllerCommandObserver, ControllerEventLoopDelay } from "./controllerTelemetry.js";
19
20
  import { parseLinuxProcessStartIdentity, removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
@@ -838,11 +839,13 @@ function safeApplicationErrorCode(code) {
838
839
  }
839
840
  }
840
841
  function safeErrorMessage(message) {
841
- const safe = message
842
+ const safe = redactAgentErrorText(message)
842
843
  .replace(/[\r\n\u2028\u2029]+/gu, " ")
843
- .trim()
844
- .slice(0, 512);
845
- return safe.length === 0 ? undefined : safe;
844
+ .trim();
845
+ if (safe.length === 0)
846
+ return undefined;
847
+ const marker = "…[truncated]";
848
+ return safe.length <= 512 ? safe : `${safe.slice(0, 512 - marker.length)}${marker}`;
846
849
  }
847
850
  export async function acquireHomeLifecycleLock(home, options = {}) {
848
851
  const lockPath = homeLifecycleLockPath(home);
@@ -17,7 +17,7 @@ export function transportAgentResult(value) {
17
17
  return {
18
18
  status: "failed",
19
19
  diagnostic: "Provider terminal event included an Agent result with an invalid NUL byte.",
20
- failureReason: "missing-result"
20
+ failureReason: "runtime-failed"
21
21
  };
22
22
  }
23
23
  if (value.trim().length === 0) {
@@ -32,7 +32,7 @@ export function transportAgentResult(value) {
32
32
  return {
33
33
  status: "failed",
34
34
  diagnostic: `Provider Agent result is ${bytes} bytes and exceeds the ${MAX_TURN_RESULT_OUTPUT_BYTES}-byte durable result limit; the result was not stored.`,
35
- failureReason: "missing-result"
35
+ failureReason: "runtime-failed"
36
36
  };
37
37
  }
38
38
  return { status: "completed", output: value };
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hasRecentTurnId, rememberRecentTurnId, validateRecentTurnIds } from "../runtime/recentTurnIds.js";
3
3
  import { roleSessionMayContinue, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
4
- import { currentProviderActivation, endProviderActivation, settleProviderTurn, settleProviderTurnSubmission, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
4
+ import { currentProviderActivation, endProviderActivation, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
6
6
  export function createRoleSessionSet(owner, activeAgentId, now) {
7
7
  const base = {
@@ -264,26 +264,28 @@ export function roleAgentSessionResumeMode(set, agentId, desired) {
264
264
  const session = set.sessions[requireSafeIdentity(agentId, "Agent id")];
265
265
  if (session === undefined)
266
266
  return "new";
267
- // A restored opaque host can be inspected/recovered only through its exact
268
- // launch fence. It cannot be resumed or silently rebound to a new launch
269
- // without a provider-native identity; an explicit verified stop still
270
- // permits the normal fresh-generation path.
267
+ // A Host status cannot supply a missing native Conversation identity.
268
+ // Replacement remains an explicit operation even after that Host ended.
271
269
  if (typeof session.nativeSessionId !== "string"
272
270
  || session.nativeSessionId.trim().length === 0) {
273
- if (session.status === "active") {
274
- throw new Error(`Role Agent session has no native Session identity: ${agentId}. `
275
- + "Restore the exact native Session or explicitly stop it before starting a fresh Session.");
276
- }
277
- return "new";
278
- }
279
- if (session.status === "ended") {
280
- return "new";
271
+ throw new Error(`Role Agent session has no native Session identity: ${agentId}. `
272
+ + "Restore the exact native Session or explicitly select a new Session.");
273
+ }
274
+ // An ended Host attachment is not evidence that the native Conversation
275
+ // vanished. Resume the exact identity; only an explicit new-Session action
276
+ // may replace it. A known unrecoverable Conversation needs that decision.
277
+ const conversation = set.owner.scope === "task"
278
+ ? set.providerBinding?.conversations.find((entry) => entry.conversationId === session.nativeSessionId)
279
+ : undefined;
280
+ if (conversation?.recoverability === "unrecoverable") {
281
+ throw new Error(`Role Agent native Session is not recoverable: ${agentId}/${session.nativeSessionId}. `
282
+ + "Explicitly select a new Session to continue; existing input attempts are not replayed.");
281
283
  }
282
284
  if (roleSessionMayContinue(session.effective, desired))
283
285
  return "resume";
284
286
  throw new Error(`Role Agent session cannot continue under the next launch: ${agentId}. `
285
- + "Its Agent, adapter or physical workspace changed; stop the existing native "
286
- + "process before starting a fresh Session.");
287
+ + "Its Agent, adapter or physical workspace changed. Explicitly select a new Session "
288
+ + "after resolving existing execution and resource ownership.");
287
289
  }
288
290
  export function bindTaskRoleProviderRuntime(set, binding, updatedAt) {
289
291
  validateRoleSessionSet(set);
@@ -325,7 +327,7 @@ export function detachRoleAgentSessionHost(set, now) {
325
327
  return set;
326
328
  const timestamp = requireDate(now, "Role Host detach timestamp");
327
329
  const { runtimeGenerationId: _runtimeGenerationId, endReason: _endReason, ...session } = active;
328
- let updated = validateRoleSessionSet({
330
+ const updated = validateRoleSessionSet({
329
331
  ...set,
330
332
  sessions: {
331
333
  ...set.sessions,
@@ -340,28 +342,10 @@ export function detachRoleAgentSessionHost(set, now) {
340
342
  if (updated.owner.scope !== "task")
341
343
  return updated;
342
344
  let taskSet = updated;
343
- let binding = taskSet.providerBinding;
344
- const turn = binding?.turn;
345
- if (binding !== null && binding !== undefined && turn !== null && turn !== undefined
346
- && turn.status === "accepted") {
347
- binding = settleProviderTurn(binding, {
348
- nativeTurnId: turn.nativeTurnId,
349
- status: "cancelled",
350
- settledAt: timestamp,
351
- reason: "runtime-physical-exit"
352
- });
353
- taskSet = updateTaskRoleProviderRuntime(taskSet, binding, now);
354
- }
355
- else if (binding !== null && binding !== undefined && turn !== null && turn !== undefined
356
- && ["submitting", "delivery-unknown"].includes(turn.status)) {
357
- binding = settleProviderTurnSubmission(binding, {
358
- attemptId: turn.attemptId,
359
- status: "rejected",
360
- resolvedAt: timestamp,
361
- reason: "runtime-physical-exit"
362
- });
363
- taskSet = updateTaskRoleProviderRuntime(taskSet, binding, now);
364
- }
345
+ const binding = taskSet.providerBinding;
346
+ // Losing the attachment proves neither cancellation nor non-submission.
347
+ // Preserve the exact input and its acceptance/unknown facts. Only a native
348
+ // terminal or an explicit submission resolution may settle that attempt.
365
349
  const activation = binding === null
366
350
  ? null
367
351
  : currentProviderActivation(binding);
@@ -158,20 +158,20 @@ export class ExecutorRegistry {
158
158
  createdAt: new Date()
159
159
  })
160
160
  });
161
- if (outcome === "delivered") {
161
+ if (outcome.result === "delivered") {
162
162
  this.#prepared.delete(input.delivery.prepared.deliveryId);
163
163
  }
164
- return outcome === "delivered" ? "sent" : outcome;
164
+ return deliveryReport(outcome);
165
165
  }
166
166
  const outcome = this.tmux.sendRoleInputOnce(input.delivery.prepared.taskId, input.delivery.prepared.roleName, input.receiptId, input.text, this.readiness(input.delivery.prepared.adapterId));
167
167
  if (outcome === "sent" || outcome === "already-sent") {
168
168
  this.#prepared.delete(input.delivery.prepared.deliveryId);
169
169
  }
170
- return outcome;
170
+ return { status: outcome };
171
171
  }
172
172
  async steerOnce(input) {
173
173
  if (this.runtimePorts === undefined)
174
- return "unavailable";
174
+ return { status: "unavailable" };
175
175
  const outcome = await this.runtimePorts.promptPush.trySteer({
176
176
  owner: { scope: "task", taskId: input.taskId, roleName: input.roleName },
177
177
  runtimeGenerationId: input.runtimeGenerationId,
@@ -187,7 +187,7 @@ export class ExecutorRegistry {
187
187
  createdAt: new Date()
188
188
  })
189
189
  });
190
- return outcome === "delivered" ? "sent" : outcome;
190
+ return deliveryReport(outcome);
191
191
  }
192
192
  async notifyOperatorInputOnce(input) {
193
193
  const probe = this.readiness(input.adapterId, "operator");
@@ -334,6 +334,17 @@ function activeTurnId(receiptId) {
334
334
  throw new Error("Turn steer receipt is invalid.");
335
335
  return decodeURIComponent(match[1]);
336
336
  }
337
+ /**
338
+ * Maps a runtime push outcome onto the Scheduler's delivery vocabulary while
339
+ * keeping the Host's structured cause attached. Only the word for success
340
+ * differs between the two layers; the failure record is forwarded unchanged.
341
+ */
342
+ function deliveryReport(outcome) {
343
+ return {
344
+ status: outcome.result === "delivered" ? "sent" : outcome.result,
345
+ ...(outcome.failure === undefined ? {} : { failure: outcome.failure })
346
+ };
347
+ }
337
348
  export function agentProcessReadinessProbe(adapterId, _surface = "role") {
338
349
  if (adapterId !== "codex" && adapterId !== "claude") {
339
350
  throw new Error(`No tmux readiness probe is registered for Agent adapter: ${adapterId}.`);
@@ -16,13 +16,11 @@ import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.j
16
16
  import { classifyWorkspacePreflight, formatWorkspacePreflightError } from "./workspacePreflightClassification.js";
17
17
  import { activeLiveRoleAgentSession } from "./agentExecutor.js";
18
18
  import { roleSessionMayContinue, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
19
- import { YUI_CONTROL_PLANE_DESCRIPTOR, createExactControlPlaneDescriptor, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
20
- import { detectRunningRelease } from "../release/runtimeRelease.js";
21
19
  import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
22
20
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
23
21
  import { builtinAgentDriverRegistry, builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
24
22
  import { managedRuntimeAdmission } from "../runtime/agentDriver.js";
25
- import { currentProviderActivation } from "../runtime/providerRuntimeIdentity.js";
23
+ import { assertProviderConversationReplaceable, currentProviderActivation } from "../runtime/providerRuntimeIdentity.js";
26
24
  import { assertCodexLaunchOverridesAvailable, inspectCodexLaunchConfig } from "./codexConfigConflict.js";
27
25
  /** Builds managed native Agent launches from the authoritative Task records. */
28
26
  export class FileRoleLaunchPlanner {
@@ -34,7 +32,7 @@ export class FileRoleLaunchPlanner {
34
32
  #createNativeSessionId;
35
33
  #cliPath;
36
34
  #inspectWorkspacePhysicalState;
37
- #controlPlane;
35
+ #entryPoint;
38
36
  #resourceRegistrarValue;
39
37
  constructor(home, store, options = {}) {
40
38
  this.home = home;
@@ -54,21 +52,10 @@ export class FileRoleLaunchPlanner {
54
52
  ?? inspectWorkspacePhysicalState;
55
53
  this.#cliPath = canonicalPath(options.cliPath
56
54
  ?? fileURLToPath(new URL("../cli.js", import.meta.url)));
57
- // Internal callbacks retain one exact command identity for receipt fencing.
58
- // Interactive Role commands use ordinary `yui`; their continuity is the
59
- // Session Manifest plus protocol/storage and durable runtime identity.
60
- const runningRelease = detectRunningRelease(this.#cliPath);
61
- this.#controlPlane = createExactControlPlaneDescriptor({
62
- executable: process.execPath,
63
- cliEntry: this.#cliPath,
64
- yuiHome: this.home,
65
- ...(runningRelease === null
66
- ? {}
67
- : {
68
- buildId: runningRelease.manifest.buildId,
69
- activeReleaseDigest: runningRelease.manifest.packageDigest
70
- })
71
- });
55
+ // Where a managed Session's commands run. Continuity is the Session
56
+ // Manifest plus protocol/storage and durable runtime identity, so no
57
+ // package or build identity belongs in this entry point.
58
+ this.#entryPoint = { executable: process.execPath, cliEntry: this.#cliPath };
72
59
  }
73
60
  #resourceRegistrar() {
74
61
  return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home);
@@ -184,6 +171,13 @@ export class FileRoleLaunchPlanner {
184
171
  && activeLiveRoleAgentSession(sessionSet) !== null) {
185
172
  throw new Error(`Task Role still has a live Session: ${task.id}/${role.name}.`);
186
173
  }
174
+ if (input.mode === "new" && sessionSet?.providerBinding !== null
175
+ && sessionSet?.providerBinding !== undefined) {
176
+ // Planning precedes broker tickets, Provider processes and native
177
+ // Conversation creation. An ended Host is not proof that its input
178
+ // attempt was rejected, cancelled, or completed.
179
+ assertProviderConversationReplaceable(sessionSet.providerBinding);
180
+ }
187
181
  return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
188
182
  purpose: activeTurn?.purpose ?? "execution"
189
183
  });
@@ -267,7 +261,7 @@ export class FileRoleLaunchPlanner {
267
261
  owner,
268
262
  roleKind: roleSessionKind(launchRole, owner, sessionPolicy.purpose),
269
263
  skills: baseSessionContext.skills,
270
- controlPlane: this.#controlPlane
264
+ entryPoint: this.#entryPoint
271
265
  });
272
266
  if (effective.contextProtocolVersion !== bootstrap.manifest.schemaVersion
273
267
  || effective.sessionManifestCompatibilityDigest
@@ -349,8 +343,7 @@ export class FileRoleLaunchPlanner {
349
343
  for (const path of [
350
344
  bootstrap.manifestPath,
351
345
  bootstrap.sessionCliPath,
352
- bootstrap.roleProfilePath,
353
- bootstrap.descriptorPath
346
+ bootstrap.roleProfilePath
354
347
  ]) {
355
348
  this.#resourceRegistrar().registerSessionContext(path, {
356
349
  home: resolve(this.home),
@@ -361,7 +354,11 @@ export class FileRoleLaunchPlanner {
361
354
  let args = [...compiled.argv];
362
355
  let command = configured.command;
363
356
  let session;
364
- if (binding.adapterId === "claude" && (managedControl || owner.scope === "global")) {
357
+ // Managed Claude owns a serialized stream with exact attempt correlation.
358
+ // Its native Hooks carry no such request fence and must not compete with
359
+ // the Host for acceptance, completion, or attachment lifecycle facts.
360
+ // Provider tool permissions remain in compiled config, not this observer.
361
+ if (binding.adapterId === "claude" && owner.scope === "global") {
365
362
  args.push("--plugin-dir", ensureClaudeLifecyclePlugin(this.home, this.#cliPath));
366
363
  }
367
364
  if (binding.adapterId === "codex") {
@@ -476,11 +473,6 @@ export class FileRoleLaunchPlanner {
476
473
  ? { YUI_AGENT_BASE_ARGS: JSON.stringify(configured.baseArgs) }
477
474
  : {}),
478
475
  ...(jobCallerKey === undefined ? {} : { YUI_JOB_CALLER_KEY: jobCallerKey }),
479
- ...(owner.scope !== "task"
480
- ? {}
481
- : {
482
- [YUI_CONTROL_PLANE_DESCRIPTOR]: serializeExactDescriptor(this.#controlPlane)
483
- }),
484
476
  ...(sessionTitle === undefined
485
477
  ? {}
486
478
  : {
@@ -716,6 +708,8 @@ function managedClaudeControlPlaneConfig(config, taskId, workItemId, turnId) {
716
708
  function isManagedYuiBashRule(rule) {
717
709
  const normalized = rule.trim();
718
710
  return /^Bash\(yui(?:\s|:\*|\*|\))/u.test(normalized)
711
+ // Yui no longer writes a control-plane digest into a managed rule; this
712
+ // shape only clears one an earlier release left in a Provider config.
719
713
  || /^Bash\(.*\s--yui-control\s/u.test(normalized);
720
714
  }
721
715
  function canonicalPath(path) {
@@ -389,9 +389,9 @@ function settleLaunchReservation(store, sessions, input) {
389
389
  const mailbox = store.getWorkMailbox(target);
390
390
  const reservation = mailbox?.processing;
391
391
  const session = sessions?.sessions[input.agentId];
392
- const runtimeGenerationId = input.nativeSessionId === undefined
393
- ? input.runtimeGenerationId ?? session?.runtimeGenerationId
394
- : session?.runtimeGenerationId;
392
+ // A late result releases only its original launch, never a successor's
393
+ // reservation merely because both Turns reused the same native Session.
394
+ const runtimeGenerationId = input.runtimeGenerationId ?? session?.runtimeGenerationId;
395
395
  if (runtimeGenerationId === undefined || !isRuntimeLaunchReservation(reservation, runtimeGenerationId))
396
396
  return;
397
397
  const settled = completeProcessing(mailbox, reservation.batchId);
@@ -149,6 +149,10 @@ function classifyTerminalSessionTurnRelation(turns, roleName, session, counts) {
149
149
  function ok(data) {
150
150
  return { status: "ok", data };
151
151
  }
152
+ /** Keeps only the payload keys the record actually carries. */
153
+ function definedAuditFields(fields) {
154
+ return Object.fromEntries(Object.entries(fields).filter((entry) => entry[1] !== undefined));
155
+ }
152
156
  function failed(error) {
153
157
  return {
154
158
  status: "error",
@@ -583,6 +587,14 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
583
587
  raw: event.payload.raw ?? "",
584
588
  inputDisposition: event.payload.inputDisposition ?? "unknown",
585
589
  sessionDisposition: event.payload.sessionDisposition ?? "unknown",
590
+ ...definedAuditFields({
591
+ registrationDisposition: event.payload.registrationDisposition,
592
+ errorName: event.payload.errorName,
593
+ causeName: event.payload.causeName,
594
+ expectedRuntimeGenerationId: event.payload.expectedRuntimeGenerationId,
595
+ observedRuntimeGenerationId: event.payload.observedRuntimeGenerationId,
596
+ attemptId: event.payload.attemptId
597
+ }),
586
598
  createdAt: event.createdAt
587
599
  });
588
600
  }
@@ -2,9 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  /**
4
4
  * Freeze the exact committed heads of a durable managed Lane workspace at the
5
- * synchronous runtime-terminalization boundary. Runtime event folds are
6
- * synchronous inside the SQLite aggregate transaction, so this small Git read
7
- * cannot use the asynchronous workspace-preparation port.
5
+ * synchronous runtime-terminalization boundary, before opening the result
6
+ * transaction. The committing fold revalidates durable workspace ownership.
8
7
  */
9
8
  export function snapshotExecutionLaneWorkspaceSync(store, workspace) {
10
9
  if (workspace.owner.type !== "execution-lane") {
@@ -71,6 +70,8 @@ function git(cwd, args) {
71
70
  return execFileSync("git", args, {
72
71
  cwd,
73
72
  encoding: "utf8",
73
+ timeout: 5_000,
74
+ maxBuffer: 512 * 1024,
74
75
  stdio: ["ignore", "pipe", "pipe"]
75
76
  }).trim();
76
77
  }
@@ -11,7 +11,7 @@ import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchr
11
11
  import { validateDraftTaskForActivation } from "../task/draftPlan.js";
12
12
  import { createTaskEvent } from "../event/taskEvent.js";
13
13
  import { enqueueWork } from "../coordination/workMailboxQueue.js";
14
- import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
14
+ import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, currentWorkItemExecutionGroup, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
15
15
  import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
16
16
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
17
17
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
@@ -473,19 +473,27 @@ export class FileTaskWorkspacePreparer {
473
473
  // durable WorkItem or ReviewRound workspace owns that cwd; Task-main
474
474
  // preparation must not move a Reviewer Session out from under an
475
475
  // active or retained ReviewRound. Other Roles use Task main.
476
- // Prefer the active Turn's exact WorkItem for this Role; fall back
477
- // to the first queued WorkItem only when no active Turn owns the Role.
476
+ // Prefer the active Turn's exact WorkItem, then a retained direct
477
+ // WorkItem owning this cwd, before considering queued assignments.
478
478
  const activeRoleTurn = tx.getActiveTurn(task.id, role.name);
479
479
  const activeTurnItem = activeRoleTurn !== null
480
480
  && activeRoleTurn.purpose === "execution"
481
481
  && activeRoleTurn.workItemId !== undefined
482
482
  ? tx.getWorkItem(task.id, activeRoleTurn.workItemId)
483
483
  : null;
484
+ // Rejection ends an execution iteration, not its workspace ownership.
485
+ // Preserve this direct WorkItem's existing cwd while dispatch prepares
486
+ // the Task before atomically reopening the failed WorkItem. Merely
487
+ // reading Task context must not migrate its resumable Worker either.
488
+ const retainedItem = tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
489
+ && candidate.status === "failed"
490
+ && currentWorkItemExecutionGroup(candidate) === undefined
491
+ && tx.getWorkItemWorkspace(task.id, candidate.id)?.root === role.workspace));
484
492
  const assignedItem = activeTurnItem !== null
485
493
  && activeTurnItem.assignee === role.name
486
494
  && !["completed", "failed", "retired"].includes(activeTurnItem.status)
487
495
  ? activeTurnItem
488
- : tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
496
+ : retainedItem ?? tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
489
497
  && !["completed", "failed", "retired"]
490
498
  .includes(candidate.status)));
491
499
  const assignedWorkspace = assignedItem === undefined
@@ -1,27 +1,21 @@
1
- import { createHash } from "node:crypto";
2
1
  import { requireIdentity } from "../domain/validation.js";
3
2
  export const TASK_FINAL_REVIEW_ARGUMENT = "--yui-task-final-review";
4
3
  export function createTaskFinalReviewContract(input) {
5
4
  const taskId = requireIdentity(input.taskId, "Task final-review contract Task id");
6
5
  const reviewerRoleName = requireIdentity(input.reviewerRoleName, "Task final-review contract Reviewer Role");
7
- const controlPlaneDigest = requireDigest(input.controlPlaneDigest, "Task final-review contract control-plane digest");
8
6
  return Object.freeze({
9
- schemaVersion: 1,
10
7
  taskId,
11
- reviewerRoleName,
12
- controlPlaneDigest,
13
- digest: contractDigest(taskId, reviewerRoleName, controlPlaneDigest)
8
+ reviewerRoleName
14
9
  });
15
10
  }
16
11
  export function validateTaskFinalReviewContract(value) {
17
- if (typeof value !== "object" || value === null || value.schemaVersion !== 1) {
18
- throw new Error("Task final-review contract must use schemaVersion 1.");
19
- }
20
- const expected = createTaskFinalReviewContract(value);
21
- const digest = requireDigest(value.digest, "Task final-review contract digest");
22
- if (digest !== expected.digest) {
23
- throw new Error("Task final-review contract digest does not match its immutable fields.");
12
+ if (typeof value !== "object" || value === null) {
13
+ throw new Error("Task final-review contract must be a record.");
24
14
  }
15
+ // A contract recorded by an earlier release also carried a version tag and the
16
+ // runtime that established it. Those fields are historical evidence and are
17
+ // never read again; the required fields below are the whole contract.
18
+ createTaskFinalReviewContract(value);
25
19
  return value;
26
20
  }
27
21
  export function taskFinalReviewConfig(contract) {
@@ -31,13 +25,15 @@ export function taskFinalReviewConfig(contract) {
31
25
  export function sameTaskFinalReviewContract(left, right) {
32
26
  if (left === undefined || right === undefined)
33
27
  return left === right;
34
- return validateTaskFinalReviewContract(left).digest
35
- === validateTaskFinalReviewContract(right).digest;
28
+ const first = validateTaskFinalReviewContract(left);
29
+ const second = validateTaskFinalReviewContract(right);
30
+ return first.taskId === second.taskId
31
+ && first.reviewerRoleName === second.reviewerRoleName;
36
32
  }
37
33
  /**
38
34
  * The contract switch is an exact CLI prefix, never an environment variable.
39
- * It must immediately follow `--yui-control <digest>` so the preflight can
40
- * bind it to the one verified Task runtime before opening mutable storage.
35
+ * It must be the first CLI argument so the preflight can bind it to the
36
+ * Leader's managed Session before opening mutable storage.
41
37
  */
42
38
  export function extractTaskFinalReviewRequest(args) {
43
39
  const index = args.indexOf(TASK_FINAL_REVIEW_ARGUMENT);
@@ -73,18 +69,3 @@ export function extractTaskFinalReviewRequest(args) {
73
69
  };
74
70
  }
75
71
  }
76
- function contractDigest(taskId, reviewerRoleName, controlPlaneDigest) {
77
- return createHash("sha256").update(JSON.stringify([
78
- "yui-task-final-review",
79
- 1,
80
- taskId,
81
- reviewerRoleName,
82
- controlPlaneDigest
83
- ])).digest("hex");
84
- }
85
- function requireDigest(value, label) {
86
- if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
87
- throw new Error(`${label} is invalid.`);
88
- }
89
- return value;
90
- }