@zq-silk/yui 0.13.4 → 0.13.5

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 (48) hide show
  1. package/README.md +8 -8
  2. package/dist/cli/commandCatalog.js +22 -21
  3. package/dist/cli/interactionPolicy.js +0 -14
  4. package/dist/cli.js +42 -0
  5. package/dist/commands/executionAuditCommands.js +1 -1
  6. package/dist/commands/taskCommands.js +60 -326
  7. package/dist/commands/taskContextCommand.js +3 -14
  8. package/dist/commands/taskExecutionCommands.js +254 -0
  9. package/dist/commands/taskNextActionCommand.js +1 -3
  10. package/dist/commands/taskOverviewCommand.js +9 -2
  11. package/dist/commands/taskRoleRuntimeStatus.js +2 -25
  12. package/dist/controller/agentRuntimeObserver.js +4 -2
  13. package/dist/controller/clientRuntime.js +45 -2
  14. package/dist/controller/controller.js +6 -3
  15. package/dist/controller/fileSchedulerStoreAdapter.js +59 -188
  16. package/dist/controller/jobControl.js +3 -2
  17. package/dist/controller/runtime.js +6 -33
  18. package/dist/controller/runtimeEventProcessor.js +8 -4
  19. package/dist/controller/runtimeHookRunFence.js +4 -10
  20. package/dist/execution/executionHealth.js +8 -16
  21. package/dist/executor/agentExecutor.js +13 -14
  22. package/dist/executor/fileRoleLaunchPlanner.js +9 -16
  23. package/dist/lifecycle/exactRunTerminalization.js +24 -322
  24. package/dist/repository/taskWorkspaceCoordinator.js +0 -9
  25. package/dist/runtime/agentHost.js +20 -80
  26. package/dist/runtime/exactControlPlane.js +15 -9
  27. package/dist/runtime/providerContinuationReconciliationService.js +1 -1
  28. package/dist/runtime/providerRecoveryDecision.js +1 -1
  29. package/dist/runtime/providerRuntimeIdentity.js +25 -15
  30. package/dist/scheduler/activeRoleRunDelivery.js +1 -19
  31. package/dist/scheduler/leaderWakeupProcessor.js +12 -63
  32. package/dist/scheduler/ports.js +3 -2
  33. package/dist/scheduler/roleRunLiveness.js +4 -1
  34. package/dist/scheduler/roleRunStall.js +0 -2
  35. package/dist/scheduler/taskExecutionProjection.js +18 -1
  36. package/dist/scheduler/wakeupQueue.js +2 -1
  37. package/dist/storage/migration/productionRegistry.js +65 -0
  38. package/dist/storage/sqliteStore.js +10 -2
  39. package/dist/storage/taskStore.js +11 -3
  40. package/dist/task/completionReadiness.js +0 -67
  41. package/dist/task/nextAction.js +16 -32
  42. package/dist/task/task.js +38 -3
  43. package/dist/web/assets/client/i18n.js +0 -4
  44. package/dist/web/assets/client/view.js +0 -18
  45. package/dist/web/webSnapshot.js +7 -13
  46. package/package.json +1 -1
  47. package/dist/run/recoveryProjection.js +0 -252
  48. package/dist/runtime/conversationSwitch.js +0 -277
@@ -14,7 +14,7 @@ import { ProviderDeliveryUnknownError, ProviderConversationMissingError, Provide
14
14
  import { sameProviderAuthorityFence, validateProviderAuthorityFence } from "./providerAuthorityFence.js";
15
15
  import { validateRuntimeProcessExitObservation } from "./processExitObservation.js";
16
16
  import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox } from "./processExitOutbox.js";
17
- import { readRuntimeStopReceipt, removeRuntimeStopReceipt, writeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
17
+ import { readRuntimeStopReceipt, removeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
18
18
  import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
19
19
  export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v2";
20
20
  const HOST_CONTROL_MAX_BYTES = 32 * 1024;
@@ -34,7 +34,6 @@ export async function runAgentHost(input) {
34
34
  let activeNativeTurnId;
35
35
  let codexClientAttachedAt;
36
36
  let consecutiveCodexDisconnects = 0;
37
- const switchDetachedSessions = new WeakSet();
38
37
  let activationId;
39
38
  let conversationRecoverability = "unknown";
40
39
  let authority;
@@ -228,8 +227,7 @@ export async function runAgentHost(input) {
228
227
  const reconnectableCodexClient = ownsCurrentSession
229
228
  && providerSession.adapterId === "codex"
230
229
  && !hostStopRequested
231
- && stopReceipt === null
232
- && !switchDetachedSessions.has(providerSession);
230
+ && stopReceipt === null;
233
231
  if (reconnectableCodexClient) {
234
232
  session = undefined;
235
233
  if (codexClientAttachedAt !== undefined
@@ -274,21 +272,19 @@ export async function runAgentHost(input) {
274
272
  hostSequence += 1;
275
273
  const observedAt = new Date().toISOString();
276
274
  const failures = [];
277
- if (!switchDetachedSessions.has(providerSession)) {
278
- try {
279
- await publishStructuredProviderActivationTerminal({
280
- home: input.home,
281
- environment: currentPayload.environment,
282
- conversationId: providerSession.conversationId,
283
- nativeSessionId: providerSession.nativeSessionId,
284
- activationId: currentActivationId,
285
- status: stopReceipt !== null || hostStopRequested ? "ended" : "failed",
286
- observedAt
287
- });
288
- }
289
- catch (error) {
290
- failures.push(`activation terminal: ${errorText(error)}`);
291
- }
275
+ try {
276
+ await publishStructuredProviderActivationTerminal({
277
+ home: input.home,
278
+ environment: currentPayload.environment,
279
+ conversationId: providerSession.conversationId,
280
+ nativeSessionId: providerSession.nativeSessionId,
281
+ activationId: currentActivationId,
282
+ status: stopReceipt !== null || hostStopRequested ? "ended" : "failed",
283
+ observedAt
284
+ });
285
+ }
286
+ catch (error) {
287
+ failures.push(`activation terminal: ${errorText(error)}`);
292
288
  }
293
289
  let exitPersisted = false;
294
290
  try {
@@ -357,10 +353,12 @@ export async function runAgentHost(input) {
357
353
  }
358
354
  const requestedAuthority = validateProviderAuthorityFence(providerControl.authority);
359
355
  const replacesCurrentConversation = session !== undefined && providerControl.kind === "new";
360
- if (!replacesCurrentConversation && authority === undefined)
356
+ if (replacesCurrentConversation) {
357
+ throw new Error("Agent Host cannot replace a live Provider Session; stop it before starting a fresh Session.");
358
+ }
359
+ if (authority === undefined)
361
360
  authority = requestedAuthority;
362
- else if (!replacesCurrentConversation
363
- && authority !== undefined
361
+ else if (authority !== undefined
364
362
  && !sameProviderAuthorityFence(authority, requestedAuthority)) {
365
363
  throw new Error("Agent Host launch carries a stale Provider authority fence.");
366
364
  }
@@ -380,43 +378,6 @@ export async function runAgentHost(input) {
380
378
  let providerAcceptedAttemptId;
381
379
  let durableInitialTurn;
382
380
  try {
383
- if (replacesCurrentConversation) {
384
- const previousSession = session;
385
- const previousPayload = sessionPayload;
386
- const previousActivationId = activationId ?? previousPayload?.launchId;
387
- if (previousPayload === undefined || previousActivationId === undefined
388
- || authority?.owner !== "controller") {
389
- throw new Error("Agent Host cannot detach an inexact Provider Activation.");
390
- }
391
- await detachDurableProviderForConversationSwitch(input.home, {
392
- taskId: requiredEnvironment(next.environment.YUI_TASK_ID, "Task id"),
393
- roleName: requiredEnvironment(next.environment.YUI_ROLE, "Role name"),
394
- runId: requiredEnvironment(next.environment.YUI_RUN_ID, "Run id"),
395
- agentId: requiredEnvironment(next.environment.YUI_AGENT_ID, "Agent id"),
396
- launchId: next.launchId,
397
- previousConversationId: previousSession.conversationId,
398
- previousNativeSessionId: previousSession.nativeSessionId,
399
- previousActivationId,
400
- nextAuthorityEpoch: requestedAuthority.epoch,
401
- nextAuthorityHolderId: requestedAuthority.holderId,
402
- observedAt: new Date().toISOString()
403
- });
404
- switchDetachedSessions.add(previousSession);
405
- // The persistent Provider child belongs to the Activation that first
406
- // created it, not to the most recent resume Run payload. The exit
407
- // observer carries that Activation launch id, so the stop receipt must
408
- // use the same identity or the expected switch detach is misclassified
409
- // as an abnormal child exit and generic cleanup can kill the Host.
410
- writeRuntimeStopReceipt(input.home, previousActivationId, new Date());
411
- authority = undefined;
412
- await terminateProviderSessionForConversationSwitch(previousSession);
413
- if (session === previousSession)
414
- session = undefined;
415
- sessionPayload = undefined;
416
- activationId = undefined;
417
- conversationRecoverability = "unknown";
418
- authority = requestedAuthority;
419
- }
420
381
  if (session !== undefined) {
421
382
  if (session.adapterId !== providerControl.adapterId
422
383
  || providerControl.mode !== "resume"
@@ -1123,9 +1084,6 @@ async function beginDurableProviderTurn(home, durableTurn) {
1123
1084
  throw error;
1124
1085
  }
1125
1086
  }
1126
- async function detachDurableProviderForConversationSwitch(home, request) {
1127
- await callControllerIdempotently(home, "runtime.conversation-switch-detach", request);
1128
- }
1129
1087
  async function callControllerIdempotently(home, method, request) {
1130
1088
  try {
1131
1089
  await callAgentController(home, method, request);
@@ -1152,24 +1110,6 @@ async function callControllerIdempotently(home, method, request) {
1152
1110
  class ControllerAcknowledgementUnknownError extends Error {
1153
1111
  name = "ControllerAcknowledgementUnknownError";
1154
1112
  }
1155
- async function terminateProviderSessionForConversationSwitch(providerSession) {
1156
- providerSession.terminate("SIGTERM");
1157
- const forceKill = setTimeout(() => providerSession.terminate("SIGKILL"), 3_000);
1158
- forceKill.unref();
1159
- let hardTimeout;
1160
- const timeout = new Promise((_resolve, reject) => {
1161
- hardTimeout = setTimeout(() => reject(new Error("Old Provider Activation did not exit after its switch authority was revoked.")), 8_000);
1162
- hardTimeout.unref();
1163
- });
1164
- try {
1165
- await Promise.race([providerSession.waitForExit(), timeout]);
1166
- }
1167
- finally {
1168
- clearTimeout(forceKill);
1169
- if (hardTimeout !== undefined)
1170
- clearTimeout(hardTimeout);
1171
- }
1172
- }
1173
1113
  async function resolveProviderTurnSubmission(home, durableTurn, error) {
1174
1114
  const attemptId = durableTurn.attemptId;
1175
1115
  if (typeof attemptId !== "string") {
@@ -268,7 +268,9 @@ export function assertExactTaskRuntimeEnvironment(runtimeSource, environment, ex
268
268
  /** Fences a descriptor to the one currently active durable Task runtime. */
269
269
  export function assertExactTaskRuntimeState(runtime, store, options = {}) {
270
270
  const task = store.getTask(runtime.taskId);
271
- if (task === null || task.status !== "active") {
271
+ if (task === null
272
+ || task.status !== "active"
273
+ || task.executionGate.state !== "enabled") {
272
274
  throw new Error("Exact Task runtime Task is not current and active.");
273
275
  }
274
276
  const role = store.getRole(runtime.taskId, runtime.roleName);
@@ -304,17 +306,21 @@ export function assertExactTaskRuntimeState(runtime, store, options = {}) {
304
306
  && session?.launchId === runtime.launchId;
305
307
  const executionRef = lifecycleMailbox?.processing?.executionRef;
306
308
  const preallocated = options.preallocatedDriverSessionReservation;
307
- const exactPreallocatedReservation = preallocated !== undefined
308
- && runtime.adapterId === preallocated.adapterId
309
- && runtime.runId !== undefined
309
+ const exactRunLaunchReservation = runtime.runId !== undefined
310
310
  && runtime.launchId !== undefined
311
- && runtime.nativeSessionId !== undefined
312
- && session === undefined
313
311
  && reservation
314
312
  && !hasRuntimeCleanupObligation(lifecycleMailbox)
315
313
  && executionRef?.type === "run"
316
314
  && executionRef.taskId === runtime.taskId
317
- && executionRef.id === runtime.runId
315
+ && executionRef.id === runtime.runId;
316
+ const terminalSessionReplacementReservation = exactRunLaunchReservation
317
+ && session !== undefined
318
+ && (session.status === "stopped" || session.status === "broken");
319
+ const exactPreallocatedReservation = preallocated !== undefined
320
+ && runtime.adapterId === preallocated.adapterId
321
+ && exactRunLaunchReservation
322
+ && runtime.nativeSessionId !== undefined
323
+ && (session === undefined || terminalSessionReplacementReservation)
318
324
  && runtime.nativeSessionId === nativeSessionIdForLaunch(preallocated.yuiHome, runtime.launchId, runtime.agentId, runtime.adapterId);
319
325
  const preallocatedBeforeInFlightProjection = exactPreallocatedReservation
320
326
  && (sessions?.inFlight === null || sessions?.inFlight === undefined);
@@ -327,11 +333,11 @@ export function assertExactTaskRuntimeState(runtime, store, options = {}) {
327
333
  throw new Error("Exact Task runtime launch fence is not current.");
328
334
  }
329
335
  if (runtime.nativeSessionId === undefined) {
330
- if (session !== undefined) {
336
+ if (session !== undefined && !terminalSessionReplacementReservation) {
331
337
  throw new Error("Exact Task runtime native Session fence is missing.");
332
338
  }
333
339
  }
334
- else if (session === undefined) {
340
+ else if (session === undefined || terminalSessionReplacementReservation) {
335
341
  if (!exactPreallocatedReservation) {
336
342
  throw new Error("Exact Task runtime native Session fence is not current.");
337
343
  }
@@ -20,7 +20,7 @@ export class ProviderContinuationReconciliationService {
20
20
  }
21
21
  async reconcile(now) {
22
22
  const changedTaskIds = new Set();
23
- for (const task of this.store.listTasks().filter((entry) => entry.status === "active")) {
23
+ for (const task of this.store.listTasks().filter((entry) => (entry.status === "active" && entry.executionGate.state === "enabled"))) {
24
24
  const events = this.store.listEvents(task.id);
25
25
  const groups = groupDetachedContinuations(projectProviderContinuations(events), events.map(runtimeObservationFromTaskEvent)
26
26
  .filter((entry) => entry !== null));
@@ -47,7 +47,7 @@ export function decideProviderRecovery(input) {
47
47
  reason: "Provider Conversation is missing but its Activation writer has not ended."
48
48
  };
49
49
  }
50
- return { action: "replace", conversationId: conversation.conversationId };
50
+ return { action: "restart-run", conversationId: conversation.conversationId };
51
51
  }
52
52
  function providerTurnIsUnsettled(binding) {
53
53
  return binding.turn !== null
@@ -294,21 +294,30 @@ export function updateProviderConversationRecoverability(raw, recoverability) {
294
294
  export function supersedeProviderConversation(raw, input) {
295
295
  const binding = validateProviderRuntimeBinding(raw);
296
296
  const current = currentProviderConversation(binding);
297
- const basis = input.basis ?? "exact-unrecoverable";
298
- if (basis !== "actor-request" && basis !== "exact-unrecoverable") {
299
- throw new Error("Provider Conversation switch basis is invalid.");
297
+ const basis = input.basis;
298
+ if (basis !== "terminal-session") {
299
+ throw new Error("Provider Conversation replacement basis is invalid.");
300
300
  }
301
- if (basis === "exact-unrecoverable" && current.recoverability !== "unrecoverable") {
302
- throw new Error("Current Provider Conversation is not exactly unrecoverable.");
303
- }
304
- if (!input.noUnsettledInputDelivery) {
305
- throw new Error("Cannot replace a Provider Conversation with unsettled input delivery.");
306
- }
307
- if (binding.authority.owner !== "none" || currentProviderActivation(binding) !== null) {
308
- throw new Error("Cannot replace a Provider Conversation while its writer umbrella is owned.");
309
- }
310
- const switchedAt = timestamp(input.switchedAt, "Provider Conversation switch timestamp");
301
+ const switchedAt = timestamp(input.switchedAt, "Provider Conversation replacement timestamp");
311
302
  const epoch = current.epoch + 1;
303
+ const terminalReason = "terminal-session-replaced";
304
+ const activations = binding.activations.map((entry) => entry.status === "active"
305
+ ? {
306
+ ...entry,
307
+ status: "failed",
308
+ endedAt: switchedAt,
309
+ terminalReason
310
+ }
311
+ : entry);
312
+ const turn = binding.turn !== null
313
+ && ["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn.status)
314
+ ? {
315
+ ...binding.turn,
316
+ status: binding.turn.turnId === undefined ? "rejected" : "failed",
317
+ updatedAt: switchedAt,
318
+ terminalReason
319
+ }
320
+ : binding.turn;
312
321
  return validateProviderRuntimeBinding({
313
322
  ...binding,
314
323
  currentConversationEpoch: epoch,
@@ -324,7 +333,7 @@ export function supersedeProviderConversation(raw, input) {
324
333
  createdAt: switchedAt
325
334
  }
326
335
  ],
327
- activations: [...binding.activations, {
336
+ activations: [...activations, {
328
337
  activationId: identity(input.activationId, "Provider Activation id"),
329
338
  conversationId: input.conversationId,
330
339
  generation: 1,
@@ -336,7 +345,8 @@ export function supersedeProviderConversation(raw, input) {
336
345
  owner: "controller",
337
346
  holderId: input.activationId,
338
347
  changedAt: switchedAt
339
- }
348
+ },
349
+ turn
340
350
  });
341
351
  }
342
352
  export function validateProviderRuntimeBinding(value) {
@@ -8,7 +8,7 @@ import { RuntimeLaunchFailure } from "../runtime/launchDiagnostics.js";
8
8
  import { mailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
9
9
  import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
10
10
  import { projectProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
11
- import { hasRuntimeLifecycleWork, RuntimeLifecycleBusyError, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
11
+ import { RuntimeLifecycleBusyError } from "../runtime/lifecycleReservation.js";
12
12
  /**
13
13
  * Delivers durable Work AgentRuns before liveness reconciliation. Task command
14
14
  * handlers only record intent; this Controller path is the sole automated
@@ -48,24 +48,6 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
48
48
  });
49
49
  continue;
50
50
  }
51
- // Single-flight: a Role runtime lifecycle lane that already holds a
52
- // launch reservation or cleanup obligation must not be entered by a
53
- // second delivery. The Run stays active-unpushed and is retried after
54
- // the lane settles; the contention is never terminalized as a failure.
55
- if (hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
56
- scope: "task",
57
- taskId: task.id,
58
- roleName: role.name
59
- })))) {
60
- results.push({
61
- taskId: task.id,
62
- roleName: role.name,
63
- runId: run.id,
64
- status: "skipped",
65
- reason: "runtime-unavailable"
66
- });
67
- continue;
68
- }
69
51
  const existingSession = store.getRoleSession(task.id, role.name, run.effective.agentId);
70
52
  const receiptId = agentRunDeliveryReceiptId(run);
71
53
  const target = { kind: "role", taskId: task.id, roleName: role.name };
@@ -2,11 +2,11 @@ import { createAgentRun } from "../run/agentRun.js";
2
2
  import { createRunAssignment } from "../context/runContextContract.js";
3
3
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
4
4
  import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession } from "../executor/effectiveLaunch.js";
5
- import { hasRuntimeLifecycleWork, RuntimeLifecycleBusyError, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
5
+ import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
6
+ import { RuntimeLifecycleBusyError } from "../runtime/lifecycleReservation.js";
6
7
  import { recordLeaderFailure } from "./leaderFailure.js";
7
8
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
8
9
  import { RuntimeLaunchError } from "../runtime/ports.js";
9
- import { roleSessionDispatchModeWithConversationSwitch } from "../runtime/conversationSwitch.js";
10
10
  export async function processLeaderWakeups(store, delivery, now, selection) {
11
11
  const results = [];
12
12
  const wakeups = selection === undefined || selection.full
@@ -20,7 +20,10 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
20
20
  for (const wakeup of wakeups) {
21
21
  const task = store.getTask(wakeup.taskId);
22
22
  const role = store.getRole(wakeup.taskId, "leader");
23
- if (task === null || task.status !== "active" || role === null) {
23
+ if (task === null
24
+ || task.status !== "active"
25
+ || task.executionGate.state !== "enabled"
26
+ || role === null) {
24
27
  results.push({ taskId: wakeup.taskId, status: "skipped", reason: "unavailable" });
25
28
  continue;
26
29
  }
@@ -29,10 +32,6 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
29
32
  results.push({ taskId: task.id, status: "skipped", reason: "workspace-not-ready" });
30
33
  continue;
31
34
  }
32
- if (store.getLeaderFailure(task.id) !== null) {
33
- results.push({ taskId: task.id, status: "skipped", reason: "recovery-blocked" });
34
- continue;
35
- }
36
35
  if (store.hasOpenInputRequest(task.id)) {
37
36
  results.push({ taskId: task.id, status: "skipped", reason: "waiting-input" });
38
37
  continue;
@@ -48,20 +47,6 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
48
47
  results.push({ taskId: task.id, status: "skipped", reason: "busy" });
49
48
  continue;
50
49
  }
51
- // Single-flight: a Role runtime lifecycle lane that already holds a
52
- // launch reservation or cleanup obligation must not be entered by a
53
- // second wake. The wake stays durable (pendingWakeup is not consumed) and
54
- // is retried after the lane settles; the suppression is recorded for the
55
- // audit instead of manufacturing a failed Run.
56
- if (hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
57
- scope: "task",
58
- taskId: task.id,
59
- roleName: role.name
60
- })))) {
61
- store.recordWakeSuppression?.(task.id, "lifecycle-busy", now);
62
- results.push({ taskId: task.id, status: "skipped", reason: "recovery-blocked" });
63
- continue;
64
- }
65
50
  const reopening = wakeup.reasons.includes("task-reopened");
66
51
  let existingSession = store.getRoleSession(task.id, role.name, reopening ? undefined : role.effective.agentId);
67
52
  let effectiveSession = existingSession;
@@ -71,43 +56,6 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
71
56
  let prepared;
72
57
  let preStartFencePersisted = false;
73
58
  try {
74
- if (existingSession !== null && !hasNativeSession(existingSession)) {
75
- const sessionIsTerminal = existingSession.status === "stopped"
76
- || existingSession.status === "broken";
77
- if (!sessionIsTerminal) {
78
- // An opaque live Session has no provider identity that can be safely
79
- // rebound. A host absence observation is not a verified stop; keep
80
- // the wake durable until an explicit exact cleanup/reset settles it.
81
- if (existingSession.launchId !== undefined) {
82
- await delivery.inspectRole({
83
- taskId: task.id,
84
- roleName: role.name,
85
- agentId: existingSession.agentId,
86
- adapterId: existingSession.adapterId
87
- });
88
- }
89
- results.push({
90
- taskId: task.id,
91
- status: "skipped",
92
- reason: "recovery-blocked"
93
- });
94
- continue;
95
- }
96
- if (hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
97
- scope: "task",
98
- taskId: task.id,
99
- roleName: role.name
100
- })))) {
101
- // A stopped/broken Session is eligible for a fresh mode only after
102
- // its exact runtime cleanup/reservation lane has settled.
103
- results.push({
104
- taskId: task.id,
105
- status: "skipped",
106
- reason: "recovery-blocked"
107
- });
108
- continue;
109
- }
110
- }
111
59
  const compatibleSession = existingSession !== null
112
60
  && (reopening
113
61
  ? effectiveLaunchSnapshotsCompatible(existingSession.effective, role.effective)
@@ -123,15 +71,14 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
123
71
  const resumableSession = hasNativeSession(existingSession)
124
72
  && existingSession.status !== "stopped"
125
73
  && existingSession.status !== "broken";
126
- // A failed or quiet Run is not proof that its Provider Conversation is
127
- // unusable. Keep resuming the same identity unless an explicit switch
128
- // request (or exact terminal/missing evidence) authorizes a fresh one.
74
+ // Reuse a healthy Session. A terminal Session is disposable and the next
75
+ // Run starts fresh without consulting historical recovery records.
129
76
  const sessionSet = store.getTaskRoleSessionSet?.(task.id, role.name) ?? null;
130
77
  const mode = reopenIdentityDrift
131
78
  ? "new"
132
79
  : sessionSet === null
133
80
  ? resumableSession && compatibleSession ? "resume" : "new"
134
- : roleSessionDispatchModeWithConversationSwitch(sessionSet, store.listEvents?.(task.id) ?? [], store.getWorkMailbox({ kind: "role", taskId: task.id, roleName: role.name }), role.name, role.effective.agentId, role.effective);
81
+ : roleAgentSessionResumeMode(sessionSet, role.effective.agentId, role.effective);
135
82
  const runId = store.peekNextAgentRunId(task.id);
136
83
  const wakeEnvelope = resolveLeaderWakeEnvelope(store, task.id);
137
84
  const contextSnapshot = store.freezeLeaderContextSnapshot?.(task.id, role.name, now);
@@ -226,7 +173,9 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
226
173
  || ready.prepared.turnAcceptedDuringLaunch === true
227
174
  || ready.prepared.turnDeliveryUnknownDuringLaunch === true;
228
175
  const latestTask = store.getTask(task.id);
229
- if (latestTask === null || latestTask.status !== "active") {
176
+ if (latestTask === null
177
+ || latestTask.status !== "active"
178
+ || latestTask.executionGate.state !== "enabled") {
230
179
  delivery.forgetPrepared?.({
231
180
  taskId: task.id,
232
181
  roleName: role.name,
@@ -25,13 +25,14 @@ export function selectedActiveSchedulerTasks(store, selection) {
25
25
  const indexedTaskIds = store.listActiveTaskIds?.();
26
26
  if (indexedTaskIds === undefined) {
27
27
  return store.listTasks().filter((task) => (task.status === "active"
28
+ && task.executionGate.state === "enabled"
28
29
  && !selection?.blockedTaskIds?.has(task.id)));
29
30
  }
30
31
  return [...indexedTaskIds].flatMap((taskId) => {
31
32
  if (selection?.blockedTaskIds?.has(taskId))
32
33
  return [];
33
34
  const task = store.getTask(taskId);
34
- return task?.status === "active" ? [task] : [];
35
+ return task?.status === "active" && task.executionGate.state === "enabled" ? [task] : [];
35
36
  });
36
37
  }
37
38
  const taskIds = selection.taskIds;
@@ -39,7 +40,7 @@ export function selectedActiveSchedulerTasks(store, selection) {
39
40
  if (selection.blockedTaskIds?.has(taskId))
40
41
  return [];
41
42
  const task = store.getTask(taskId);
42
- return task?.status === "active" ? [task] : [];
43
+ return task?.status === "active" && task.executionGate.state === "enabled" ? [task] : [];
43
44
  });
44
45
  }
45
46
  /** Resolves either every Role in a selected Task or only explicit Role keys. */
@@ -196,7 +196,10 @@ function exactBatchInventory(batch, candidates) {
196
196
  return { statuses, resources, hostExits };
197
197
  }
198
198
  function isResourceCandidate(task, run, now) {
199
- if (task.status !== "active" || run.status !== "active" || run.deliveredAt === undefined) {
199
+ if (task.status !== "active"
200
+ || task.executionGate.state !== "enabled"
201
+ || run.status !== "active"
202
+ || run.deliveredAt === undefined) {
200
203
  return false;
201
204
  }
202
205
  const deliveredAt = Date.parse(run.deliveredAt);
@@ -14,8 +14,6 @@ export const RUN_STALLED_EVENT = "run.stalled";
14
14
  export const RUN_RECOVERED_EVENT = "run.recovered";
15
15
  export const RUN_DIAGNOSTIC_FINISHED_EVENT = "runtime.diagnostic-finished";
16
16
  /** Structured, non-Message recovery evidence written by an explicit Leader. */
17
- export const RUN_RECOVERY_REQUESTED_EVENT = "run.recovery-requested";
18
- export const RUN_RECOVERY_APPLIED_EVENT = "run.recovery-applied";
19
17
  /** Workflow-semantic events that count for the durable progress clock. */
20
18
  const ACTIVITY_EVENT_TYPES = new Set([
21
19
  RUN_PROGRESS_EVENT,
@@ -132,12 +132,29 @@ export function projectTaskExecution(facts) {
132
132
  ...(run.executionGroupId === undefined ? {} : { executionGroupId: run.executionGroupId }),
133
133
  ...(run.executionLaneId === undefined ? {} : { executionLaneId: run.executionLaneId })
134
134
  }));
135
- const monitoring = task.status === "completed"
135
+ const monitoring = task.executionGate.state === "stopped"
136
+ || task.status === "completed"
136
137
  || task.status === "retired"
137
138
  || task.status === "archived"
138
139
  ? "stopped"
139
140
  : "active";
140
141
  if (monitoring === "stopped") {
142
+ if (task.executionGate.state === "stopped" && task.status === "active") {
143
+ return render({
144
+ task,
145
+ status: "stopped",
146
+ owner: "operator",
147
+ action: "start-execution",
148
+ summary: `Task ${task.id} execution is stopped; durable progress is preserved.`,
149
+ reason: "execution-stopped",
150
+ monitoring,
151
+ failClosed: false,
152
+ activeRuns: activeRunViews,
153
+ attention: [],
154
+ blockers: [],
155
+ pendingWakeup
156
+ });
157
+ }
141
158
  const stoppedStatus = task.status;
142
159
  return render({
143
160
  task,
@@ -11,7 +11,8 @@ export function queueLeaderWakeup(store, taskId, reason, now) {
11
11
  export function queueLeaderWakeupAfterYield(store, task, run, now) {
12
12
  if (run.taskId !== task.id)
13
13
  throw new Error(`AgentRun belongs to another Task: ${run.taskId}.`);
14
- if (task.status !== "active" || run.roleName === "leader")
14
+ if (task.status !== "active" || task.executionGate.state !== "enabled" || run.roleName === "leader") {
15
15
  return null;
16
+ }
16
17
  return queueLeaderWakeup(store, task.id, wakeReason("role-result"), now);
17
18
  }
@@ -24,6 +24,8 @@ const TASK_FROM_VERSION = 3;
24
24
  const TASK_TO_VERSION = 4;
25
25
  const TASK_INTENT_FROM_VERSION = 4;
26
26
  const TASK_INTENT_TO_VERSION = 5;
27
+ const TASK_EXECUTION_GATE_FROM_VERSION = 5;
28
+ const TASK_EXECUTION_GATE_TO_VERSION = 6;
27
29
  const WORK_ITEM_FROM_VERSION = 6;
28
30
  const WORK_ITEM_TO_VERSION = 7;
29
31
  const WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION = 7;
@@ -161,6 +163,7 @@ export function createProductionStorageRegistry() {
161
163
  .registerCompatible(projectLifecycleStep())
162
164
  .registerOfflineMigration(taskWorkspaceIdentityStep())
163
165
  .registerOfflineMigration(taskIntentStep())
166
+ .registerOfflineMigration(taskExecutionGateStep())
164
167
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
165
168
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION, WORK_ITEM_GIT_SNAPSHOT_TO_VERSION, "workItems"))
166
169
  .registerOfflineMigration(workItemExecutionGroupHistoryStep())
@@ -3375,6 +3378,68 @@ function migrateTaskV4ToV5(snapshot) {
3375
3378
  state: { ...snapshot.state, tasks: nextTasks }
3376
3379
  };
3377
3380
  }
3381
+ /** Task v6 separates semantic lifecycle from the current execution admission gate. */
3382
+ function taskExecutionGateStep() {
3383
+ return {
3384
+ axis: "record",
3385
+ recordKind: "task",
3386
+ fromVersion: TASK_EXECUTION_GATE_FROM_VERSION,
3387
+ toVersion: TASK_EXECUTION_GATE_TO_VERSION,
3388
+ preconditions: requireTaskV5Family,
3389
+ transform: migrateTaskV5ToV6,
3390
+ declaredEffects: []
3391
+ };
3392
+ }
3393
+ function requireTaskV5Family(snapshot) {
3394
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
3395
+ if (manifestVersions.task !== TASK_EXECUTION_GATE_FROM_VERSION) {
3396
+ throw new Error(`Record task migration requires manifest version ${TASK_EXECUTION_GATE_FROM_VERSION}.`);
3397
+ }
3398
+ if (snapshot.state === null)
3399
+ return;
3400
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
3401
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
3402
+ const aggregate = asObject(rawTask, `Task aggregate ${taskId}`);
3403
+ if (aggregate.task === undefined)
3404
+ continue;
3405
+ const record = asObject(aggregate.task, `Task ${taskId}`);
3406
+ if (record.schemaVersion !== TASK_EXECUTION_GATE_FROM_VERSION) {
3407
+ throw new Error(`Task ${taskId} must use schemaVersion ${TASK_EXECUTION_GATE_FROM_VERSION}.`);
3408
+ }
3409
+ }
3410
+ }
3411
+ function migrateTaskV5ToV6(snapshot) {
3412
+ requireTaskV5Family(snapshot);
3413
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
3414
+ const schemaManifest = {
3415
+ ...snapshot.schemaManifest,
3416
+ recordVersions: { ...manifestVersions, task: TASK_EXECUTION_GATE_TO_VERSION }
3417
+ };
3418
+ if (snapshot.state === null)
3419
+ return { schemaManifest, state: null };
3420
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
3421
+ const nextTasks = {};
3422
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
3423
+ const aggregate = asObject(rawTask, `Task aggregate ${taskId}`);
3424
+ if (aggregate.task === undefined) {
3425
+ nextTasks[taskId] = { ...aggregate };
3426
+ continue;
3427
+ }
3428
+ const task = asObject(aggregate.task, `Task ${taskId}`);
3429
+ nextTasks[taskId] = {
3430
+ ...aggregate,
3431
+ task: {
3432
+ ...task,
3433
+ schemaVersion: TASK_EXECUTION_GATE_TO_VERSION,
3434
+ executionGate: { state: "enabled" }
3435
+ }
3436
+ };
3437
+ }
3438
+ return {
3439
+ schemaManifest,
3440
+ state: { ...snapshot.state, tasks: nextTasks }
3441
+ };
3442
+ }
3378
3443
  /**
3379
3444
  * A version bump is deliverable only when the shared planner resolves the full
3380
3445
  * adjacent path. This also covers target-only record families as explicit 0->1