@zq-silk/yui 0.12.3 → 0.12.4

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.
@@ -6,7 +6,7 @@ import { defaultTableWidth, renderTable } from "../output/table.js";
6
6
  import { createIntegrationAttempt, recordResolutionDecision, supersedeIntegration, updateIntegrationAttempt } from "../integration/integrationAttempt.js";
7
7
  import { GitIntegrationService } from "../integration/gitIntegrationService.js";
8
8
  import { runTaskIntegrationQueueCommand } from "./taskIntegrationQueueCommands.js";
9
- import { taskActor } from "./taskActor.js";
9
+ import { taskLocalActor } from "./taskActor.js";
10
10
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
11
11
  import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
12
12
  export async function runTaskIntegrationCommand(args, store, home, options = {}) {
@@ -18,12 +18,13 @@ export async function runTaskIntegrationCommand(args, store, home, options = {})
18
18
  return continueIntegration(rest, store, home, now, options);
19
19
  }
20
20
  if (command === "resolve") {
21
- return resolveDecision(rest, store, now, options.environment);
21
+ return resolveDecision(rest, store, now, options.environment, home);
22
22
  }
23
23
  if (command === "abort")
24
- return abortIntegration(rest, store, now(), options);
25
- if (command === "supersede")
26
- return supersedeIntegrationCommand(rest, store, now(), options.environment);
24
+ return abortIntegration(rest, store, now(), options, home);
25
+ if (command === "supersede") {
26
+ return supersedeIntegrationCommand(rest, store, now(), options.environment, home);
27
+ }
27
28
  if (command === "cleanup") {
28
29
  return cleanupIntegration(rest, store, home, options.environment);
29
30
  }
@@ -43,6 +44,7 @@ async function cleanupIntegration(args, store, home, environment) {
43
44
  throw usageError("Task Integration cleanup usage: yui task integration cleanup <task>/<integration>.");
44
45
  }
45
46
  const integration = requireIntegration(store, args[0], environment);
47
+ taskLocalActor(store, environment, integration.taskId, home);
46
48
  if (integration.status !== "committed"
47
49
  && integration.status !== "superseded"
48
50
  && integration.status !== "failed") {
@@ -83,6 +85,7 @@ async function start(args, store, home, now, options) {
83
85
  if (task.status !== "active") {
84
86
  throw usageError(`Task is not active: ${task.id}/${task.status}.`);
85
87
  }
88
+ taskLocalActor(store, options.environment, task.id, home);
86
89
  const changeSetIds = parsed.many.get("--change-set") ?? [];
87
90
  if (changeSetIds.length === 0)
88
91
  throw usageError("--change-set is required.", usage);
@@ -135,6 +138,7 @@ async function start(args, store, home, now, options) {
135
138
  // record creation are atomic, so a concurrent Leader cannot sneak a
136
139
  // duplicate Integration between the guard and the write.
137
140
  const integration = store.transaction((tx) => {
141
+ taskLocalActor(tx, options.environment, task.id, home);
138
142
  const guard = runDeliveryGuardPreflight(tx, task.id, {
139
143
  kind: "integration-start",
140
144
  projectId: project.id,
@@ -165,6 +169,7 @@ async function continueIntegration(args, store, home, now, options) {
165
169
  throw usageError(usage);
166
170
  const integration = requireIntegration(store, parsed.positionals[0], options.environment);
167
171
  requireActiveIntegrationTask(store, integration);
172
+ taskLocalActor(store, options.environment, integration.taskId, home);
168
173
  if (integration.status !== "validating"
169
174
  && integration.status !== "running"
170
175
  && (integration.status !== "blocked"
@@ -184,25 +189,28 @@ async function runIntegration(store, home, integration, now, options) {
184
189
  : `Integration ${result.attempt.id} failed; target ref was not advanced\n`;
185
190
  return { output, data: result };
186
191
  }
187
- function resolveDecision(args, store, now, environment) {
192
+ function resolveDecision(args, store, now, environment, home) {
188
193
  const usage = "Task Integration resolve usage: yui task integration resolve <task>/<integration> --option <manual-resolution|reject> --rationale <text>.";
189
194
  const parsed = parseRepeatable(args, new Set(), new Set(["--option", "--rationale"]), usage);
190
195
  if (parsed.positionals.length !== 1)
191
196
  throw usageError(usage);
192
- const integration = requireIntegration(store, parsed.positionals[0], environment);
193
- const task = requireActiveIntegrationTask(store, integration);
194
- if (taskActor(environment, task.id) !== "leader") {
195
- throw usageError("Only the Task Leader can resolve an Integration conflict.");
196
- }
197
197
  const selectedOption = parsed.one.get("--option");
198
198
  const rationale = parsed.one.get("--rationale");
199
199
  if (selectedOption === undefined || rationale === undefined)
200
200
  throw usageError(usage);
201
- const resolved = recordResolutionDecision(integration, {
202
- action: selectedOption,
203
- rationale
204
- }, now());
205
- store.saveIntegrationAttempt(resolved.taskId, resolved);
201
+ const resolved = store.transaction((tx) => {
202
+ const integration = requireIntegration(tx, parsed.positionals[0], environment);
203
+ const task = requireActiveIntegrationTask(tx, integration);
204
+ if (taskLocalActor(tx, environment, task.id, home) !== "leader") {
205
+ throw usageError("Only the Task Leader can resolve an Integration conflict.");
206
+ }
207
+ const updated = recordResolutionDecision(integration, {
208
+ action: selectedOption,
209
+ rationale
210
+ }, now());
211
+ tx.saveIntegrationAttempt(updated.taskId, updated);
212
+ return updated;
213
+ });
206
214
  return {
207
215
  output: selectedOption === "manual-resolution"
208
216
  ? `Leader selected manual resolution for ${resolved.id}; resolve its candidate worktree, then run integration continue\n`
@@ -210,13 +218,14 @@ function resolveDecision(args, store, now, environment) {
210
218
  data: { integration: resolved }
211
219
  };
212
220
  }
213
- async function abortIntegration(args, store, now, options) {
221
+ async function abortIntegration(args, store, now, options, home) {
214
222
  const usage = "Task Integration abort usage: yui task integration abort <task>/<integration> --reason <text>.";
215
223
  const parsed = parseRepeatable(args, new Set(), new Set(["--reason"]), usage);
216
224
  if (parsed.positionals.length !== 1)
217
225
  throw usageError(usage);
218
226
  const integration = requireIntegration(store, parsed.positionals[0], options.environment);
219
227
  requireActiveIntegrationTask(store, integration);
228
+ taskLocalActor(store, options.environment, integration.taskId, home);
220
229
  if (integration.status !== "running" && integration.status !== "blocked") {
221
230
  throw usageError(`Integration cannot be aborted from ${integration.status}: ${integration.id}.`);
222
231
  }
@@ -238,6 +247,7 @@ async function abortIntegration(args, store, now, options) {
238
247
  if (current.status !== "running" && current.status !== "blocked") {
239
248
  throw usageError(`Integration cannot be aborted from ${current.status}: ${current.id}.`);
240
249
  }
250
+ taskLocalActor(tx, options.environment, current.taskId, home);
241
251
  const aborted = updateIntegrationAttempt(current, {
242
252
  status: "failed",
243
253
  checks: [
@@ -252,7 +262,7 @@ async function abortIntegration(args, store, now, options) {
252
262
  };
253
263
  });
254
264
  }
255
- function supersedeIntegrationCommand(args, store, now, environment) {
265
+ function supersedeIntegrationCommand(args, store, now, environment, home) {
256
266
  const usage = "Task Integration supersede usage: yui task integration supersede <task>/<integration> --reason <text>.";
257
267
  const parsed = parseRepeatable(args, new Set(), new Set(["--reason"]), usage);
258
268
  if (parsed.positionals.length !== 1)
@@ -267,7 +277,7 @@ function supersedeIntegrationCommand(args, store, now, environment) {
267
277
  throw usageError(usage);
268
278
  // Only the Task Leader (or Operator/user) may supersede a committed
269
279
  // Integration: it rewrites delivery-baseline evidence and audit history.
270
- taskActor(environment, integration.taskId);
280
+ taskLocalActor(store, environment, integration.taskId, home);
271
281
  // A queue-backed committed Attempt cannot be superseded: the queue entry
272
282
  // would remain in its current status while its Attempt becomes "superseded",
273
283
  // leaving contradictory terminal records that never converge. This covers
@@ -287,6 +297,7 @@ function supersedeIntegrationCommand(args, store, now, environment) {
287
297
  if (current.status !== "committed") {
288
298
  throw usageError(`Integration cannot be superseded from ${current.status}: ${current.id}.`);
289
299
  }
300
+ taskLocalActor(tx, environment, current.taskId, home);
290
301
  const superseded = supersedeIntegration(current, reason, now);
291
302
  tx.saveIntegrationAttempt(superseded.taskId, superseded);
292
303
  return {
@@ -3,7 +3,7 @@ import { defaultTableWidth, renderTable } from "../output/table.js";
3
3
  import { resolveProject } from "../repository/project.js";
4
4
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
5
5
  import { enqueueIntegrationQueueEntry, processIntegrationQueue, reconcileIntegrationQueueEntry, requeueIntegrationQueueEntry, supersedeIntegrationQueueEntry } from "../integration/integrationQueueService.js";
6
- import { taskActor } from "./taskActor.js";
6
+ import { taskLocalActor } from "./taskActor.js";
7
7
  import { parseRepeatable } from "./taskIntegrationCommands.js";
8
8
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
9
9
  /**
@@ -22,11 +22,11 @@ export async function runTaskIntegrationQueueCommand(args, store, home, options
22
22
  if (command === "process")
23
23
  return process(rest, store, home, options);
24
24
  if (command === "supersede")
25
- return supersede(rest, store, options);
25
+ return supersede(rest, store, options, home);
26
26
  if (command === "requeue")
27
- return requeue(rest, store, options);
27
+ return requeue(rest, store, options, home);
28
28
  if (command === "reconcile")
29
- return reconcile(rest, store, options);
29
+ return reconcile(rest, store, options, home);
30
30
  throw usageError(command === undefined
31
31
  ? "Task Integration queue command is required."
32
32
  : `Unknown command: task integration queue ${command}`);
@@ -37,7 +37,7 @@ async function enqueue(args, store, options, home) {
37
37
  if (parsed.positionals.length !== 1)
38
38
  throw usageError(usage);
39
39
  const task = requireActiveTask(store, parsed.positionals[0]);
40
- requireLeader(options.environment, task.id);
40
+ requireLeader(store, options.environment, task.id, home);
41
41
  const projectRef = parsed.one.get("--project");
42
42
  const changeSetId = parsed.one.get("--change-set");
43
43
  if (projectRef === undefined || changeSetId === undefined)
@@ -120,7 +120,7 @@ async function process(args, store, home, options) {
120
120
  if (parsed.positionals.length !== 1)
121
121
  throw usageError(usage);
122
122
  const task = requireActiveTask(store, parsed.positionals[0]);
123
- requireLeader(options.environment, task.id);
123
+ requireLeader(store, options.environment, task.id, home);
124
124
  const limitValue = parsed.one.get("--limit");
125
125
  const limit = limitValue === undefined ? undefined : Number.parseInt(limitValue, 10);
126
126
  if (limitValue !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
@@ -148,13 +148,13 @@ async function process(args, store, home, options) {
148
148
  : `${lines.join("\n")}\n`;
149
149
  return { output, data: { processed } };
150
150
  }
151
- function supersede(args, store, options) {
151
+ function supersede(args, store, options, home) {
152
152
  const usage = "Task Integration queue supersede usage: yui task integration queue supersede <task>/<entry> --reason <text>.";
153
153
  const parsed = parseRepeatable(args, new Set(), new Set(["--reason"]), usage);
154
154
  if (parsed.positionals.length !== 1)
155
155
  throw usageError(usage);
156
156
  const entry = requireQueueEntry(store, parsed.positionals[0], options.environment);
157
- requireLeader(options.environment, entry.taskId);
157
+ requireLeader(store, options.environment, entry.taskId, home);
158
158
  const reason = parsed.one.get("--reason");
159
159
  if (reason === undefined)
160
160
  throw usageError(usage);
@@ -164,24 +164,24 @@ function supersede(args, store, options) {
164
164
  data: { entry: superseded }
165
165
  };
166
166
  }
167
- function requeue(args, store, options) {
167
+ function requeue(args, store, options, home) {
168
168
  const usage = "Task Integration queue requeue usage: yui task integration queue requeue <task>/<entry>.";
169
169
  if (args.length !== 1)
170
170
  throw usageError(usage);
171
171
  const entry = requireQueueEntry(store, args[0], options.environment);
172
- requireLeader(options.environment, entry.taskId);
172
+ requireLeader(store, options.environment, entry.taskId, home);
173
173
  const waiting = requeueIntegrationQueueEntry(store, entry.taskId, entry.id, options.now ?? (() => new Date()));
174
174
  return {
175
175
  output: `Requeued ${waiting.id}; it will be processed again on the next queue run\n`,
176
176
  data: { entry: waiting }
177
177
  };
178
178
  }
179
- async function reconcile(args, store, options) {
179
+ async function reconcile(args, store, options, home) {
180
180
  const usage = "Task Integration queue reconcile usage: yui task integration queue reconcile <task>/<entry>.";
181
181
  if (args.length !== 1)
182
182
  throw usageError(usage);
183
183
  const entry = requireQueueEntry(store, args[0], options.environment);
184
- requireLeader(options.environment, entry.taskId);
184
+ requireLeader(store, options.environment, entry.taskId, home);
185
185
  const committed = await reconcileIntegrationQueueEntry(store, entry.taskId, entry.id, new NodeGitWorkspace(), options.now ?? (() => new Date()));
186
186
  return {
187
187
  output: `Reconciled ${committed.id} as committed -> ${committed.targetAfter ?? "-"}\n`,
@@ -221,8 +221,8 @@ function requireQueueEntry(store, value, environment) {
221
221
  }
222
222
  return entry;
223
223
  }
224
- function requireLeader(environment, taskId) {
225
- if (taskActor(environment, taskId) !== "leader") {
224
+ function requireLeader(store, environment, taskId, home) {
225
+ if (taskLocalActor(store, environment, taskId, home) !== "leader") {
226
226
  throw usageError("Only the Task Leader can change the integration queue.");
227
227
  }
228
228
  }
@@ -1,3 +1,4 @@
1
+ import { rejectedYieldAttemptFromTaskEvent, RUN_YIELD_REJECTED_EVENT } from "../run/rejectedYieldAttempt.js";
1
2
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
2
3
  import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
3
4
  import { RUN_BOOTSTRAP_MAX_DELTAS } from "./runContextContract.js";
@@ -426,6 +427,20 @@ function collectAuthorizedContext(store, run) {
426
427
  for (const event of publishedTreeAuthorizations.reverse().slice(-16)) {
427
428
  result.push(materialize("L4", "task-event", event.id, event));
428
429
  }
430
+ for (const event of events.filter(({ type }) => (type === RUN_YIELD_REJECTED_EVENT)).slice(-16)) {
431
+ result.push(materialize("L4", "task-event", event.id, event));
432
+ const attempt = rejectedYieldAttemptFromTaskEvent(event);
433
+ if (attempt === null)
434
+ continue;
435
+ const rejectedRun = store.getAgentRun(task.id, attempt.runId);
436
+ if (rejectedRun !== null) {
437
+ result.push(materialize("L4", "agent-run", rejectedRun.id, rejectedRun));
438
+ }
439
+ const rejectedRound = store.getReviewRound(task.id, attempt.reviewRoundId);
440
+ if (rejectedRound !== null) {
441
+ result.push(materialize("L3", "review-round", rejectedRound.id, rejectedRound));
442
+ }
443
+ }
429
444
  for (const request of store.listOpenInputRequests([task.id])) {
430
445
  result.push(materialize("L4", "input-request", request.id, request));
431
446
  }
@@ -1714,8 +1714,24 @@ export class FileSchedulerStoreAdapter {
1714
1714
  }
1715
1715
  }
1716
1716
  }
1717
- store.saveRole(task.id, updateRoleStatus(role, "exited", input.now));
1717
+ const blocksAutomaticLeaderRecovery = role.name === "leader"
1718
+ && input.leaderRecovery === "blocked";
1719
+ store.saveRole(task.id, updateRoleStatus(role, blocksAutomaticLeaderRecovery ? "failed" : "exited", input.now));
1718
1720
  stopTaskSessionIfPresent(store, task.id, role.name, currentRun.effective.agentId, input.now);
1721
+ if (blocksAutomaticLeaderRecovery) {
1722
+ const failure = recordLeaderFailure(task.id, input.session?.nativeSessionId ?? "(unregistered)", input.summary, input.now, store.getLeaderFailure(task.id));
1723
+ store.saveLeaderFailure(failure);
1724
+ recordLeaderAttentionRequired(store, {
1725
+ taskId: task.id,
1726
+ reason: "leader-recovery-failed",
1727
+ payload: {
1728
+ message: failure.message,
1729
+ runId: currentRun.id
1730
+ },
1731
+ now: input.now
1732
+ });
1733
+ return "failed";
1734
+ }
1719
1735
  queueLeaderWakeup(store, task.id, wakeReason(role.name === "leader" ? "leader-run-failed" : "role-run-failed"), input.now);
1720
1736
  return "failed";
1721
1737
  });
@@ -1,5 +1,4 @@
1
- import { isDeepStrictEqual } from "node:util";
2
- import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
1
+ import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
3
2
  function ownerLabel(owner) {
4
3
  switch (owner.type) {
5
4
  case "task":
@@ -43,11 +42,13 @@ export function classifyWorkspacePreflight(store, task, roleName, activeRun, ins
43
42
  };
44
43
  }
45
44
  // 2. Run snapshot validation: when the active Run carries a workspace
46
- // snapshot it must match the durable ManagedWorkspace exactly. A
47
- // mismatch means the Run was created against a stale baseline.
45
+ // snapshot its launch-stable identity must match the durable
46
+ // ManagedWorkspace. Audit timestamps may change without changing the
47
+ // owner, root, or Project entries authorized for the Run.
48
48
  if (activeRun?.workspace !== undefined) {
49
49
  const durableRunWorkspace = store.getManagedWorkspace(activeRun.workspace.owner);
50
- if (durableRunWorkspace === null || !isDeepStrictEqual(durableRunWorkspace, activeRun.workspace)) {
50
+ if (durableRunWorkspace === null
51
+ || !sameManagedWorkspaceIdentity(durableRunWorkspace, activeRun.workspace)) {
51
52
  const diff = [];
52
53
  if (durableRunWorkspace === null) {
53
54
  diff.push(`durable ManagedWorkspace for owner ${JSON.stringify(activeRun.workspace.owner)} is missing`);
@@ -9,7 +9,7 @@ import { finishReviewRound, updateReviewExecutionGroup } from "../review/reviewR
9
9
  import { reconcileReviewFindingsAfterReview } from "../review/reviewFindingLedger.js";
10
10
  import { agentRunDeliveryReceiptId, failAgentRun, withYieldReceipt, yieldAgentRun } from "../run/agentRun.js";
11
11
  import { createYieldReceipt } from "../run/yieldReceipt.js";
12
- import { recordExecutionLaneResult } from "../execution/executionGroup.js";
12
+ import { recordExecutionLaneResult, resolveExecutionGroup } from "../execution/executionGroup.js";
13
13
  import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
14
14
  import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
15
15
  import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
@@ -145,7 +145,7 @@ export function terminalizeExactRunReviewRound(store, input, now) {
145
145
  return validation;
146
146
  }
147
147
  const reviewRound = validation.round;
148
- const groupedRound = input.run.executionGroupId !== undefined
148
+ let groupedRound = input.run.executionGroupId !== undefined
149
149
  && input.run.executionLaneId !== undefined
150
150
  && reviewRound.executionGroup !== undefined
151
151
  ? updateReviewExecutionGroup(reviewRound, recordExecutionLaneResult(reviewRound.executionGroup, input.run.executionLaneId, {
@@ -174,10 +174,25 @@ export function terminalizeExactRunReviewRound(store, input, now) {
174
174
  : { gitSnapshot: input.reviewResult.gitSnapshot })
175
175
  }, input.outcome.status === "yielded" ? "completed" : "failed", now))
176
176
  : reviewRound;
177
+ if (input.settleFailedExecutionGroup === true
178
+ && input.outcome.status === "failed"
179
+ && groupedRound.executionGroup !== undefined
180
+ && groupedRound.executionGroup.resolution === undefined
181
+ && groupedRound.executionGroup.lanes.every((lane) => (lane.status === "yielded"
182
+ || lane.status === "completed"
183
+ || lane.status === "failed"
184
+ || lane.status === "skipped"))) {
185
+ groupedRound = updateReviewExecutionGroup(groupedRound, resolveExecutionGroup(groupedRound.executionGroup, {
186
+ decision: "blocked",
187
+ summary: input.outcome.summary
188
+ }, now));
189
+ }
177
190
  const groupedMultiLane = groupedRound.executionGroup !== undefined
178
191
  && (groupedRound.executionGroup.lanes.length > 1
179
192
  || groupedRound.executionGroup.strategy.mode === "adaptive");
180
- if (groupedMultiLane && groupedRound.executionGroup !== undefined) {
193
+ if (groupedMultiLane
194
+ && groupedRound.executionGroup !== undefined
195
+ && groupedRound.executionGroup.resolution === undefined) {
181
196
  // A panel Lane only contributes evidence. The Leader must see every
182
197
  // terminal Lane and explicitly resolve the Group before this ReviewRound
183
198
  // can become terminal.
@@ -193,6 +208,108 @@ export function terminalizeExactRunReviewRound(store, input, now) {
193
208
  }
194
209
  return { disposition: "applied", round: terminal };
195
210
  }
211
+ /**
212
+ * Retire one stranded active Run only after its exact Provider Turn is
213
+ * terminal and every durable execution fence is quiet. The caller owns the
214
+ * surrounding aggregate transaction so the Run, ReviewRound/Lane, mailbox,
215
+ * Session, and append-only retirement record commit together.
216
+ */
217
+ export function retireExactActiveAgentRun(store, input, now) {
218
+ const current = store.getAgentRun(input.taskId, input.runId);
219
+ const stateChanged = (reason) => ({
220
+ disposition: "state-changed",
221
+ run: current,
222
+ ...(current === null ? {} : { progressAt: latestRunDurableProgressAt(store, input.taskId, input.roleName, input.runId)?.progressAt }),
223
+ reason
224
+ });
225
+ const task = store.getTask(input.taskId);
226
+ if (task === null)
227
+ return stateChanged("task-missing");
228
+ if (task.status !== "active")
229
+ return stateChanged("task-terminal");
230
+ if (current === null)
231
+ return stateChanged("run-missing");
232
+ if (current.status !== "active")
233
+ return stateChanged("run-terminal");
234
+ if (current.taskId !== input.taskId || current.roleName !== input.roleName) {
235
+ return stateChanged("run-owner-mismatch");
236
+ }
237
+ if (current.effective.agentId !== input.agentId
238
+ || current.effective.adapterId !== input.adapterId) {
239
+ return stateChanged("run-launch-identity-mismatch");
240
+ }
241
+ const active = current.executionGroupId !== undefined && current.executionLaneId !== undefined
242
+ ? store.getActiveExecutionLaneRun(input.taskId, current.executionGroupId, current.executionLaneId)
243
+ : store.getActiveAgentRun(input.taskId, input.roleName);
244
+ if (active?.id !== current.id)
245
+ return stateChanged("active-run-mismatch");
246
+ const progress = latestRunDurableProgressAt(store, input.taskId, input.roleName, input.runId);
247
+ if (progress === null)
248
+ return stateChanged("progress-unavailable");
249
+ if (progress.progressAt !== input.expectedProgressAt) {
250
+ return { ...stateChanged("progress-fence-mismatch"), progressAt: progress.progressAt };
251
+ }
252
+ if (!matchesRecoverySessionFence(store, {
253
+ ...input,
254
+ action: "terminate",
255
+ providerAcceptance: current.deliveredAt === undefined ? "rejected" : "accepted",
256
+ now
257
+ }))
258
+ return stateChanged("session-or-launch-fence-mismatch");
259
+ const blocker = exactRecoveryExecutionBlocker(store, current);
260
+ if (blocker !== null) {
261
+ return {
262
+ disposition: "blocked",
263
+ run: current,
264
+ progressAt: progress.progressAt,
265
+ reason: blocker
266
+ };
267
+ }
268
+ const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
269
+ const session = sessions?.sessions[input.agentId];
270
+ const providerBinding = sessions?.providerBinding;
271
+ const providerTurn = providerBinding?.turn;
272
+ const providerSettled = providerBinding?.runId === current.id
273
+ && (providerTurn?.status === "completed"
274
+ || providerTurn?.status === "failed"
275
+ || providerTurn?.status === "cancelled"
276
+ || providerTurn?.status === "rejected");
277
+ if (session?.status !== "stopped" && session?.status !== "broken" && !providerSettled) {
278
+ return {
279
+ disposition: "blocked",
280
+ run: current,
281
+ progressAt: progress.progressAt,
282
+ reason: "runtime-not-terminal"
283
+ };
284
+ }
285
+ const terminal = terminalizeExactTaskRun(store, {
286
+ taskId: input.taskId,
287
+ roleName: input.roleName,
288
+ agentId: input.agentId,
289
+ runId: input.runId,
290
+ receiptId: agentRunDeliveryReceiptId(current),
291
+ ...(input.nativeSessionId === undefined ? {} : { nativeSessionId: input.nativeSessionId }),
292
+ ...(input.launchId === undefined ? {} : { launchId: input.launchId }),
293
+ settleFailedExecutionGroup: true,
294
+ outcome: { status: "failed", summary: input.reason }
295
+ }, now);
296
+ if (terminal.disposition !== "applied" || terminal.run === null) {
297
+ return stateChanged(terminal.reason ?? "terminalization-fence-mismatch");
298
+ }
299
+ if (terminal.run.purpose === "execution" && terminal.run.workItemId !== undefined) {
300
+ const item = store.getWorkItem(input.taskId, terminal.run.workItemId);
301
+ if (item !== null
302
+ && !["completed", "failed", "retired"].includes(item.status)
303
+ && !workItemOwnsUnresolvedExecutionLane(item, terminal.run.executionGroupId, terminal.run.executionLaneId)) {
304
+ store.saveWorkItem(input.taskId, updateWorkItemStatus(item, "failed", now, input.reason));
305
+ }
306
+ }
307
+ return {
308
+ disposition: "applied",
309
+ run: terminal.run,
310
+ progressAt: progress.progressAt
311
+ };
312
+ }
196
313
  /**
197
314
  * Applies one exact application-level terminal fact inside the caller's
198
315
  * FileTaskStore transaction. All caller-owned outcome records can therefore
@@ -279,7 +396,10 @@ export function terminalizeExactTaskRun(store, input, now) {
279
396
  taskId: input.taskId,
280
397
  run,
281
398
  outcome: input.outcome,
282
- reviewResult: input.reviewResult
399
+ reviewResult: input.reviewResult,
400
+ ...(input.settleFailedExecutionGroup === undefined
401
+ ? {}
402
+ : { settleFailedExecutionGroup: input.settleFailedExecutionGroup })
283
403
  }, now);
284
404
  if (reviewRoundTerminalization.disposition !== "applied") {
285
405
  return obsolete(run, reviewRoundTerminalization.reason ?? "review-round-mismatch");
@@ -313,7 +433,7 @@ export function terminalizeExactTaskRun(store, input, now) {
313
433
  ? undefined
314
434
  : workItemExecutionGroupById(item, run.executionGroupId);
315
435
  if (item !== null && group !== undefined) {
316
- const grouped = recordExecutionLaneResult(group, run.executionLaneId, {
436
+ let grouped = recordExecutionLaneResult(group, run.executionLaneId, {
317
437
  summary: input.outcome.summary,
318
438
  ...(input.reviewResult?.report === undefined ? {} : { report: input.reviewResult.report }),
319
439
  ...(input.reviewResult?.checks === undefined ? {} : { checks: input.reviewResult.checks }),
@@ -322,6 +442,18 @@ export function terminalizeExactTaskRun(store, input, now) {
322
442
  ...(input.reviewResult?.evidenceCommit === undefined ? {} : { evidenceCommit: input.reviewResult.evidenceCommit }),
323
443
  ...(input.reviewResult?.gitSnapshot === undefined ? {} : { gitSnapshot: input.reviewResult.gitSnapshot })
324
444
  }, input.outcome.status === "yielded" ? "completed" : "failed", now);
445
+ if (input.settleFailedExecutionGroup === true
446
+ && input.outcome.status === "failed"
447
+ && grouped.resolution === undefined
448
+ && grouped.lanes.every((lane) => (lane.status === "yielded"
449
+ || lane.status === "completed"
450
+ || lane.status === "failed"
451
+ || lane.status === "skipped"))) {
452
+ grouped = resolveExecutionGroup(grouped, {
453
+ decision: "blocked",
454
+ summary: input.outcome.summary
455
+ }, now);
456
+ }
325
457
  store.saveWorkItem(input.taskId, updateWorkItemExecutionGroup(item, grouped, now));
326
458
  }
327
459
  }
@@ -643,6 +775,12 @@ function matchesSessionFence(sessions, input) {
643
775
  && inFlight.receiptId === input.receiptId);
644
776
  }
645
777
  function matchesLaunchFence(store, sessions, input) {
778
+ // A resumed Run shares the native Provider Session whose process environment
779
+ // was created for an earlier Run. Once that exact native Session has passed
780
+ // matchesSessionFence, its immutable per-launch environment is not a second
781
+ // Run identity. Opaque hosts still require the launch fence below.
782
+ if (input.nativeSessionId !== undefined)
783
+ return true;
646
784
  if (input.launchId === undefined)
647
785
  return true;
648
786
  const session = sessions?.sessions[input.agentId];
@@ -664,7 +802,9 @@ function settleLaunchReservation(store, sessions, input) {
664
802
  const mailbox = store.getWorkMailbox(target);
665
803
  const reservation = mailbox?.processing;
666
804
  const session = sessions?.sessions[input.agentId];
667
- const launchId = input.launchId ?? session?.launchId;
805
+ const launchId = input.nativeSessionId === undefined
806
+ ? input.launchId ?? session?.launchId
807
+ : session?.launchId;
668
808
  if (launchId === undefined || !isRuntimeLaunchReservation(reservation, launchId))
669
809
  return;
670
810
  const settled = completeProcessing(mailbox, reservation.batchId);