@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
@@ -0,0 +1,254 @@
1
+ import { createTaskEvent } from "../event/taskEvent.js";
2
+ import { terminalizeTaskRoleRunSession } from "../executor/agentExecutor.js";
3
+ import { recordExecutionLaneResult } from "../execution/executionGroup.js";
4
+ import { usageError } from "../errors/cliError.js";
5
+ import { requestDurableJobCancel } from "../job/durableJob.js";
6
+ import { finishReviewRound, updateReviewExecutionGroup } from "../review/reviewRound.js";
7
+ import { updateRoleStatus } from "../role/role.js";
8
+ import { failAgentRun } from "../run/agentRun.js";
9
+ import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
10
+ import { startTaskExecution, stopTaskExecution } from "../task/task.js";
11
+ import { updateWorkItemExecutionGroup, workItemExecutionGroupById } from "../workItem/workItem.js";
12
+ import { taskActor } from "./taskActor.js";
13
+ export function parseTaskExecutionStopRequest(args) {
14
+ const taskId = args[0];
15
+ if (taskId === undefined || taskId.startsWith("--")) {
16
+ throw usageError("Task execution stop usage: yui task execution stop <task> --force --reason <text>.");
17
+ }
18
+ let force = false;
19
+ let reason;
20
+ for (let index = 1; index < args.length; index += 1) {
21
+ const argument = args[index];
22
+ if (argument === "--force" && !force) {
23
+ force = true;
24
+ continue;
25
+ }
26
+ if (argument === "--reason" && reason === undefined) {
27
+ reason = args[index + 1];
28
+ index += 1;
29
+ continue;
30
+ }
31
+ throw usageError(`Unknown or duplicated Task execution stop option: ${String(argument)}.`);
32
+ }
33
+ if (!force)
34
+ throw usageError("Task execution stop requires --force.");
35
+ const normalizedReason = reason?.trim();
36
+ if (normalizedReason === undefined
37
+ || normalizedReason.length === 0
38
+ || normalizedReason.startsWith("--")) {
39
+ throw usageError("Task execution stop requires --reason <text>.");
40
+ }
41
+ return { taskId, reason: normalizedReason };
42
+ }
43
+ export function parseTaskExecutionStartRequest(args) {
44
+ if (args.length !== 1 || args[0].startsWith("--")) {
45
+ throw usageError("Task execution start usage: yui task execution start <task>.");
46
+ }
47
+ return args[0];
48
+ }
49
+ /**
50
+ * Fence execution before touching the physical runtime. Progress records and
51
+ * workspaces remain intact; only current attempts and delivery claims end.
52
+ */
53
+ export function stopTaskExecutionCommand(request, store, options = {}) {
54
+ const now = options.now?.() ?? new Date();
55
+ const actor = requireOperatorOrUser(options.environment, request.taskId);
56
+ return store.transaction((tx) => {
57
+ const task = tx.getTask(request.taskId);
58
+ if (task === null)
59
+ throw usageError(`Task not found: ${request.taskId}.`);
60
+ if (task.status !== "active") {
61
+ throw usageError(`Only an active Task can be stopped: ${task.id}.`);
62
+ }
63
+ const changed = task.executionGate.state !== "stopped";
64
+ tx.saveTask(stopTaskExecution(task, now));
65
+ const activeRuns = tx.listAgentRuns(task.id).filter((run) => run.status === "active");
66
+ const activeJobs = tx.listDurableJobs(task.id)
67
+ .filter((job) => job.status === "queued" || job.status === "running");
68
+ for (const job of activeJobs) {
69
+ tx.saveDurableJob(task.id, requestDurableJobCancel(job, now));
70
+ }
71
+ failExecutionAttempts(tx, task.id, activeRuns, request.reason, now);
72
+ for (const run of activeRuns) {
73
+ tx.saveAgentRun(failAgentRun(run, `Task execution stopped: ${request.reason}`, now));
74
+ if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
75
+ tx.clearActiveExecutionLaneRun(task.id, run.executionGroupId, run.executionLaneId);
76
+ }
77
+ tx.clearActiveAgentRun(task.id, run.roleName);
78
+ }
79
+ const roleNames = taskRuntimeRoleNames(tx, task.id, activeRuns);
80
+ for (const roleName of roleNames) {
81
+ tx.clearActiveAgentRun(task.id, roleName);
82
+ const sessions = tx.getTaskRoleSessionSet(task.id, roleName);
83
+ if (sessions !== null && sessions.inFlight !== null) {
84
+ tx.saveTaskRoleSessionSet(terminalizeCurrentRun(sessions, now));
85
+ }
86
+ const role = tx.getRole(task.id, roleName);
87
+ if (role !== null && role.status !== "idle") {
88
+ tx.saveRole(task.id, updateRoleStatus(role, "idle", now));
89
+ }
90
+ }
91
+ // A stop is allowed to discard stale pointer projections even when their
92
+ // historical Runs are already terminal.
93
+ for (const run of tx.listAgentRuns(task.id)) {
94
+ if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
95
+ tx.clearActiveExecutionLaneRun(task.id, run.executionGroupId, run.executionLaneId);
96
+ }
97
+ }
98
+ for (const mailbox of tx.listWorkMailboxes()) {
99
+ if ("taskId" in mailbox.target && mailbox.target.taskId === task.id) {
100
+ tx.removeWorkMailbox(mailbox.target);
101
+ }
102
+ }
103
+ tx.clearLeaderFailure(task.id);
104
+ tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, "task.execution-stopped", {
105
+ by: actor,
106
+ reason: request.reason,
107
+ terminatedRuns: String(activeRuns.length)
108
+ }, now));
109
+ return {
110
+ taskId: task.id,
111
+ changed,
112
+ roleNames,
113
+ terminatedRunIds: activeRuns.map(({ id }) => id),
114
+ cancelledJobIds: activeJobs.map(({ id }) => id),
115
+ output: changed
116
+ ? `Stopped Task execution: ${task.id}. Progress was preserved; `
117
+ + `${activeRuns.length} active attempt(s) were terminated and `
118
+ + `${activeJobs.length} DurableJob(s) were cancelled.`
119
+ : `Task execution is already stopped: ${task.id}. Runtime cleanup will be verified.`
120
+ };
121
+ });
122
+ }
123
+ /** Enable execution only after the caller has proven all old physical writers absent. */
124
+ export function startTaskExecutionCommand(taskId, store, options = {}) {
125
+ const now = options.now?.() ?? new Date();
126
+ const actor = requireOperatorOrUser(options.environment, taskId);
127
+ return store.transaction((tx) => {
128
+ const task = tx.getTask(taskId);
129
+ if (task === null)
130
+ throw usageError(`Task not found: ${taskId}.`);
131
+ if (task.status !== "active") {
132
+ throw usageError(`Only an active Task can be started: ${task.id}.`);
133
+ }
134
+ if (task.executionGate.state === "enabled") {
135
+ return { taskId: task.id, changed: false, output: `Task execution is already enabled: ${task.id}.` };
136
+ }
137
+ if (tx.listAgentRuns(task.id).some((run) => run.status === "active")
138
+ || tx.listDurableJobs(task.id).some((job) => job.status === "queued" || job.status === "running")) {
139
+ throw usageError(`Task still has an active execution attempt: ${task.id}.`);
140
+ }
141
+ for (const mailbox of tx.listWorkMailboxes()) {
142
+ if ("taskId" in mailbox.target && mailbox.target.taskId === task.id) {
143
+ tx.removeWorkMailbox(mailbox.target);
144
+ }
145
+ }
146
+ tx.clearLeaderFailure(task.id);
147
+ tx.saveTask(startTaskExecution(task, now));
148
+ queueLeaderWakeup(tx, task.id, "execution-started", now);
149
+ tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, "task.execution-started", { by: actor }, now));
150
+ return {
151
+ taskId: task.id,
152
+ changed: true,
153
+ output: `Started Task execution: ${task.id}. The Leader will continue from durable progress.`
154
+ };
155
+ });
156
+ }
157
+ /** Remove cleanup-generated delivery records after physical release is proven. */
158
+ export function finalizeStoppedTaskExecution(taskId, store) {
159
+ store.transaction((tx) => {
160
+ const task = tx.getTask(taskId);
161
+ if (task === null)
162
+ throw usageError(`Task not found: ${taskId}.`);
163
+ if (task.executionGate.state !== "stopped") {
164
+ throw usageError(`Task execution is not stopped: ${taskId}.`);
165
+ }
166
+ for (const mailbox of tx.listWorkMailboxes()) {
167
+ if ("taskId" in mailbox.target && mailbox.target.taskId === taskId) {
168
+ tx.removeWorkMailbox(mailbox.target);
169
+ }
170
+ }
171
+ tx.clearLeaderFailure(taskId);
172
+ });
173
+ }
174
+ function requireOperatorOrUser(environment, taskId) {
175
+ const actor = taskActor(environment, taskId);
176
+ if (actor === "leader") {
177
+ throw usageError("Task execution stop/start requires the global Operator or a human user.");
178
+ }
179
+ return actor;
180
+ }
181
+ function terminalizeCurrentRun(sessions, now) {
182
+ const inFlight = sessions.inFlight;
183
+ return terminalizeTaskRoleRunSession(sessions, {
184
+ agentId: inFlight.agentId,
185
+ runId: inFlight.runId,
186
+ receiptId: inFlight.receiptId
187
+ }, now);
188
+ }
189
+ function taskRuntimeRoleNames(store, taskId, runs) {
190
+ const names = new Set(store.listRoles(taskId).map(({ name }) => name));
191
+ for (const sessions of store.listRoleSessionSets(taskId))
192
+ names.add(sessions.owner.roleName);
193
+ for (const run of runs)
194
+ names.add(run.roleName);
195
+ for (const owner of store.listSessionOwners()) {
196
+ if (owner.owner.scope === "task" && owner.owner.taskId === taskId) {
197
+ names.add(owner.owner.roleName);
198
+ }
199
+ }
200
+ return [...names].sort();
201
+ }
202
+ function failExecutionAttempts(store, taskId, runs, reason, now) {
203
+ const summary = `Task execution stopped: ${reason}`;
204
+ const workItems = new Map(store.listWorkItems(taskId).map((item) => [item.id, item]));
205
+ const reviewRounds = new Map(store.listReviewRounds(taskId).map((round) => [round.id, round]));
206
+ const affectedReviewRoundIds = new Set();
207
+ for (const run of runs) {
208
+ if (run.executionGroupId === undefined || run.executionLaneId === undefined)
209
+ continue;
210
+ if (run.workItemId !== undefined) {
211
+ const item = workItems.get(run.workItemId);
212
+ const group = item === undefined
213
+ ? undefined
214
+ : workItemExecutionGroupById(item, run.executionGroupId);
215
+ const lane = group?.lanes.find(({ id }) => id === run.executionLaneId);
216
+ if (item !== undefined && group !== undefined && lane !== undefined
217
+ && item.currentExecutionGroupId === group.id
218
+ && !["yielded", "completed", "failed", "skipped"].includes(lane.status)) {
219
+ const updated = updateWorkItemExecutionGroup(item, recordExecutionLaneResult(group, lane.id, { summary }, "failed", now), now);
220
+ workItems.set(item.id, updated);
221
+ }
222
+ }
223
+ if (run.reviewRoundId !== undefined) {
224
+ affectedReviewRoundIds.add(run.reviewRoundId);
225
+ const round = reviewRounds.get(run.reviewRoundId);
226
+ const group = round?.executionGroup?.id === run.executionGroupId
227
+ ? round.executionGroup
228
+ : undefined;
229
+ const lane = group?.lanes.find(({ id }) => id === run.executionLaneId);
230
+ if (round !== undefined && group !== undefined && lane !== undefined
231
+ && !["yielded", "completed", "failed", "skipped"].includes(lane.status)) {
232
+ reviewRounds.set(round.id, updateReviewExecutionGroup(round, recordExecutionLaneResult(group, lane.id, { summary }, "failed", now)));
233
+ }
234
+ }
235
+ }
236
+ for (const item of workItems.values()) {
237
+ const original = store.getWorkItem(item.taskId, item.id);
238
+ if (original !== null && original.revision !== item.revision) {
239
+ store.saveWorkItem(item.taskId, item);
240
+ }
241
+ }
242
+ for (const reviewRoundId of affectedReviewRoundIds) {
243
+ const round = reviewRounds.get(reviewRoundId);
244
+ const original = store.getReviewRound(round.taskId, round.id);
245
+ if (original === null)
246
+ continue;
247
+ const changed = JSON.stringify(original.executionGroup) !== JSON.stringify(round.executionGroup);
248
+ const terminal = round.status === "pending" || round.status === "running"
249
+ ? finishReviewRound(round, "failed", summary, now)
250
+ : round;
251
+ if (changed || terminal !== round)
252
+ store.saveReviewRound(round.taskId, terminal);
253
+ }
254
+ }
@@ -5,7 +5,6 @@ import { extractReviewFindings, planRepairWave } from "../task/repairWave.js";
5
5
  import { projectTaskOrchestration } from "../observability/orchestrationMetrics.js";
6
6
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
7
7
  import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
8
- import { projectExecutionLaneRunRecoveries } from "../run/recoveryProjection.js";
9
8
  import { projectReviewDecision } from "../review/reviewDecision.js";
10
9
  /**
11
10
  * Issue 07 (Leader convergence): read-only `yui task next-action <task>`.
@@ -42,8 +41,7 @@ export function runTaskNextActionCommand(args, store, currentTaskReviewCandidate
42
41
  const actionFacts = {
43
42
  ...facts,
44
43
  currentTaskReviewCandidate,
45
- executionGroups: execution.executionGroups,
46
- runRecoveries: projectExecutionLaneRunRecoveries(reader, taskId, execution.executionGroups)
44
+ executionGroups: execution.executionGroups
47
45
  };
48
46
  const action = projectNextAction(actionFacts);
49
47
  const repairWave = repairWaveFor(action, actionFacts);
@@ -123,14 +123,21 @@ function buildTaskOverviewEntry(task, store, now, runtimeHealthPolicy) {
123
123
  now,
124
124
  runtimeHealthPolicy
125
125
  });
126
- const next = execution.action === "recover-execution"
126
+ const next = execution.status === "stopped"
127
127
  ? {
128
128
  action: execution.action,
129
129
  owner: execution.owner,
130
130
  kind: "execution",
131
131
  summary: execution.summary
132
132
  }
133
- : legacyNext;
133
+ : execution.action === "recover-execution"
134
+ ? {
135
+ action: execution.action,
136
+ owner: execution.owner,
137
+ kind: "execution",
138
+ summary: execution.summary
139
+ }
140
+ : legacyNext;
134
141
  return {
135
142
  ...task,
136
143
  brief,
@@ -1,6 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
3
- import { hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
+ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
4
  import { isRoleRunStalled, latestStallProgressAt } from "../scheduler/roleRunStall.js";
5
5
  import { createRuntimeObservation, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
6
6
  import { classifyRuntimeHealth, projectRuntimeMailbox, projectRuntimeObservation, projectRuntimeTaskEvents, runtimeDisplayStatus } from "../runtime/runtimeProjection.js";
@@ -9,7 +9,6 @@ import { resolveRuntimeHealth } from "../config/yuiConfig.js";
9
9
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
10
10
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
11
11
  import { projectSessionTokenMetrics, resolveSessionTokenIdentity } from "../runtime/sessionTokenMetrics.js";
12
- import { freshConversationLaunchBlockers, projectConversationSwitch } from "../runtime/conversationSwitch.js";
13
12
  export function inspectTaskRoleRuntimeStatuses(taskId, roles, store, panes, now = new Date()) {
14
13
  const taskOpenInputRequestCount = store.listInputRequests(taskId)
15
14
  .filter((request) => request.status === "open").length;
@@ -94,15 +93,9 @@ export function renderTaskRoleRuntimeStatus(status) {
94
93
  ? `needs-attention (${status.stall.kind ?? "workflow-not-progressing"}; no workflow progress since ${status.stall.progressAt ?? "unknown"})`
95
94
  : "none"}`,
96
95
  ` Native session ${nativeSession}`,
97
- ` Session switch ${status.conversationSwitch === null
98
- ? "none"
99
- : `${status.conversationSwitch.status} (${status.conversationSwitch.requestId}; ${status.conversationSwitch.reason})`}`,
100
96
  ` Agent runtime ${runtime}`,
101
97
  ` Session tokens ${sessionTokens}`,
102
98
  ` Runtime cleanup ${status.runtimeCleanupPending ? "pending" : "none"}`,
103
- ` Fresh launch ${status.freshLaunchAllowed
104
- ? "allowed"
105
- : `blocked (${status.freshLaunchBlockers.join(", ")})`}`,
106
99
  ` tmux pane ${tmux}`,
107
100
  ` Workspace ${status.workspace.managed ? status.workspace.root : status.workspace.path}`,
108
101
  ...workspaceDetails
@@ -127,26 +120,12 @@ export function taskRoleNativeSessionLabel(status) {
127
120
  return status.nativeSession?.status ?? "unbound";
128
121
  }
129
122
  export function inspectTaskRoleSessionRecovery(taskId, roleName, store) {
130
- const sessions = store.getTaskRoleSessionSet(taskId, roleName);
131
123
  const target = runtimeLifecycleTarget({ scope: "task", taskId, roleName });
132
124
  const runtimeMailbox = store.getWorkMailbox(target);
133
- const roleMailbox = store.getWorkMailbox({ kind: "role", taskId, roleName });
134
- const switchBlockers = freshConversationLaunchBlockers({
135
- sessions,
136
- events: store.listEvents(taskId),
137
- mailbox: roleMailbox,
138
- roleName
139
- });
140
- const freshLaunchBlockers = [
141
- ...switchBlockers,
142
- ...(hasRuntimeLifecycleWork(runtimeMailbox) ? ["runtime-lifecycle-busy"] : [])
143
- ];
144
125
  return {
145
126
  taskId,
146
127
  roleName,
147
- runtimeCleanupPending: hasRuntimeCleanupObligation(runtimeMailbox),
148
- freshLaunchAllowed: freshLaunchBlockers.length === 0,
149
- freshLaunchBlockers
128
+ runtimeCleanupPending: hasRuntimeCleanupObligation(runtimeMailbox)
150
129
  };
151
130
  }
152
131
  export function taskRoleOpenInputLabel(status) {
@@ -203,7 +182,6 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
203
182
  ? { managed: false, path: role.workspace }
204
183
  : { ...managedWorkspace, managed: true };
205
184
  const events = store.listEvents(taskId);
206
- const conversationSwitch = projectConversationSwitch(events, role.name, sessions);
207
185
  const sessionTokens = projectSessionTokenMetrics(events, resolveSessionTokenIdentity(nativeSession === null
208
186
  ? null
209
187
  : { taskId, roleName: role.name, ...nativeSession }));
@@ -243,7 +221,6 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
243
221
  tmux,
244
222
  workspace,
245
223
  sessionTokens,
246
- conversationSwitch,
247
224
  runtime,
248
225
  stall
249
226
  };
@@ -242,11 +242,13 @@ export class AgentRuntimeObserver {
242
242
  // FileTaskStore remains a development/compatibility fallback. The normal
243
243
  // Controller store is SQLite and must discover only its indexed hot set.
244
244
  const activeTasks = indexedTaskIds === undefined
245
- ? this.store.listTasks().filter((task) => task.status === "active")
245
+ ? this.store.listTasks().filter((task) => (task.status === "active" && task.executionGate.state === "enabled"))
246
246
  : [...new Set(indexedTaskIds)]
247
247
  .sort(numericCompare)
248
248
  .map((taskId) => this.store.getTask(taskId))
249
- .filter((task) => (task !== null && task.status === "active"));
249
+ .filter((task) => (task !== null
250
+ && task.status === "active"
251
+ && task.executionGate.state === "enabled"));
250
252
  for (const task of activeTasks) {
251
253
  // A Task still incurs one O(E) event projection. Group those observations
252
254
  // by Run and sort each group once so every active Run can reuse the same
@@ -427,10 +427,12 @@ export class FileTaskWorkflowRuntime {
427
427
  this.clientOptions = clientOptions;
428
428
  }
429
429
  notifyStateChanged(taskId) {
430
- this.notifyMailboxChanged({ kind: "task", taskId });
430
+ void this.notifyMailboxChanged({ kind: "task", taskId });
431
431
  }
432
432
  notifyMailboxChanged(target) {
433
- void callFileTaskController(this.home, "scheduler.signal", { key: controllerMailboxKey(target) }, this.clientOptions).catch(this.clientOptions.onError ?? (() => { }));
433
+ const pending = callFileTaskController(this.home, "scheduler.signal", { key: controllerMailboxKey(target) }, this.clientOptions).then(() => { });
434
+ void pending.catch(this.clientOptions.onError ?? (() => { }));
435
+ return pending;
434
436
  }
435
437
  reconcileTask(taskId) {
436
438
  void this.#prepareAndScan(taskId).catch(this.clientOptions.onError ?? (() => { }));
@@ -469,12 +471,52 @@ export class FileTaskWorkflowRuntime {
469
471
  }
470
472
  }
471
473
  }
474
+ /** Wait until every cancellation requested by Task execution stop is physically settled. */
475
+ async stopTaskDurableJobs(taskId) {
476
+ const deadline = Date.now() + LIFECYCLE_REQUEST_TIMEOUT_MS;
477
+ for (;;) {
478
+ const active = this.store.listActiveDurableJobs()
479
+ .filter((job) => job.taskId === taskId);
480
+ if (active.length === 0)
481
+ return;
482
+ await callFileTaskController(this.home, "scheduler.scan", {}, {
483
+ ...this.clientOptions,
484
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
485
+ });
486
+ const remaining = this.store.listActiveDurableJobs()
487
+ .filter((job) => job.taskId === taskId);
488
+ if (remaining.length === 0)
489
+ return;
490
+ if (Date.now() >= deadline) {
491
+ throw new Error(`DurableJobs did not stop: ${remaining.map(({ id }) => `${taskId}/${id}`).join(", ")}.`);
492
+ }
493
+ await new Promise((resolve) => setTimeout(resolve, 100));
494
+ }
495
+ }
472
496
  /**
473
497
  * Issue 03 archive postcondition. Re-verifies physical absence after the
474
498
  * runtime stop and blocks archive while any owned Provider root is still
475
499
  * live, preserving the owner records for Operator recovery.
476
500
  */
477
501
  async assertTaskPhysicalResourcesReleased(taskId) {
502
+ const activeJobs = this.store.listActiveDurableJobs()
503
+ .filter((job) => job.taskId === taskId);
504
+ if (activeJobs.length > 0) {
505
+ throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task physical resources are not released: ${activeJobs.length} DurableJob(s) are still active: `
506
+ + activeJobs.map(({ id }) => id).join(", "));
507
+ }
508
+ const liveSessions = this.store.listRoleSessionSets(taskId).flatMap((sessions) => (Object.values(sessions.sessions)
509
+ .filter((session) => session.status !== "stopped" && session.status !== "broken")
510
+ .map((session) => `${sessions.owner.roleName}/${session.agentId}/${session.status}`)));
511
+ if (liveSessions.length > 0) {
512
+ throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task physical resources are not released: current Role Session state is still live: `
513
+ + liveSessions.join(", "));
514
+ }
515
+ const livePanes = this.tmux.inspectTaskRolePanes(taskId).filter((pane) => !pane.dead);
516
+ if (livePanes.length > 0) {
517
+ throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task physical resources are not released: ${livePanes.length} tmux Role pane(s) are still live: `
518
+ + livePanes.map(({ roleName, pid }) => `${roleName} (pid ${pid ?? "?"})`).join(", "));
519
+ }
478
520
  const reconciliation = new SessionOwnerReconciliation({
479
521
  home: this.home,
480
522
  store: this.store,
@@ -596,6 +638,7 @@ export class FileTaskWorkflowRuntime {
596
638
  const task = this.store.getTask(taskId);
597
639
  if (task !== null
598
640
  && task.status === "active"
641
+ && task.executionGate.state === "enabled"
599
642
  && this.workspacePreparer !== undefined) {
600
643
  await this.workspacePreparer.prepareTaskWorkspace(taskId);
601
644
  }
@@ -658,7 +658,7 @@ async function prepareActiveWorkspaces(store, workspace, selection, maintenanceF
658
658
  if (selection.blockedTaskIds?.has(taskId))
659
659
  return [];
660
660
  const task = store.getTask(taskId);
661
- return task?.status === "active" ? [task] : [];
661
+ return task?.status === "active" && task.executionGate.state === "enabled" ? [task] : [];
662
662
  });
663
663
  const failed = new Set();
664
664
  const ready = new Set();
@@ -1383,7 +1383,8 @@ export class FileTaskController {
1383
1383
  #scheduleTaskPassRetry(scope) {
1384
1384
  if (this.#stopped || this.#pendingFull)
1385
1385
  return;
1386
- if (this.store.getTask(scope.taskId)?.status !== "active") {
1386
+ const task = this.store.getTask(scope.taskId);
1387
+ if (task?.status !== "active" || task.executionGate.state !== "enabled") {
1387
1388
  this.#clearTaskPassRetry(scope.taskId);
1388
1389
  return;
1389
1390
  }
@@ -1410,7 +1411,9 @@ export class FileTaskController {
1410
1411
  if (this.#stopped || this.#pendingFull)
1411
1412
  return;
1412
1413
  try {
1413
- if (this.store.getTask(scope.taskId)?.status !== "active") {
1414
+ const currentTask = this.store.getTask(scope.taskId);
1415
+ if (currentTask?.status !== "active"
1416
+ || currentTask.executionGate.state !== "enabled") {
1414
1417
  this.#clearTaskPassRetry(scope.taskId);
1415
1418
  return;
1416
1419
  }