@adhdev/daemon-core 0.9.82-rc.469 → 0.9.82-rc.470

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.
@@ -881,7 +881,7 @@ export function enqueueTask(
881
881
  const maxRetries = typeof opts?.maxRetries === 'number' && Number.isFinite(opts.maxRetries) && opts.maxRetries >= 0
882
882
  ? Math.floor(opts.maxRetries)
883
883
  : undefined;
884
- return withQueueLock(meshId, () => {
884
+ const result = withQueueLock(meshId, () => {
885
885
  if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
886
886
  throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
887
887
  }
@@ -927,6 +927,10 @@ export function enqueueTask(
927
927
  MeshRuntimeStore.getInstance().insertQueueEntry(entry);
928
928
  return entry;
929
929
  });
930
+ // A fresh pending task returns its mission to a non-terminal state — reset any
931
+ // stale close-candidate marker so a later re-completion can nudge again.
932
+ scheduleMissionCloseCandidateCheck(meshId, [result]);
933
+ return result;
930
934
  }
931
935
 
932
936
  /**
@@ -1070,9 +1074,17 @@ function resolveDependencyFailurePolicy(meshId: string): DependencyFailurePolicy
1070
1074
  *
1071
1075
  * Must be called inside the queue lock of the triggering transition.
1072
1076
  */
1073
- function propagateDependencyFailure(meshId: string, failedTaskId: string): void {
1077
+ /**
1078
+ * Cascade a dependency failure. Returns the dependents whose status was flipped to
1079
+ * `cancelled` (the 'cancel' policy) so the caller can trigger mission_close_candidate
1080
+ * detection for their missions too — a cascade can be the very transition that leaves
1081
+ * a *different* mission all-terminal. Under the 'block' policy nothing goes terminal
1082
+ * (dependents are only marked blocked), so the returned list is empty.
1083
+ */
1084
+ function propagateDependencyFailure(meshId: string, failedTaskId: string): MeshWorkQueueEntry[] {
1074
1085
  const policy = resolveDependencyFailurePolicy(meshId);
1075
1086
  const store = MeshRuntimeStore.getInstance();
1087
+ const cancelled: MeshWorkQueueEntry[] = [];
1076
1088
  const frontier = [failedTaskId];
1077
1089
  const seen = new Set<string>(frontier);
1078
1090
  while (frontier.length > 0) {
@@ -1087,6 +1099,7 @@ function propagateDependencyFailure(meshId: string, failedTaskId: string): void
1087
1099
  dependent.cancelledAt = new Date().toISOString();
1088
1100
  dependent.cancelReason = `dependency_failed:${currentId}`;
1089
1101
  store.updateQueueEntry(dependent);
1102
+ cancelled.push(dependent);
1090
1103
  frontier.push(dependent.id); // cascade to transitive dependents
1091
1104
  } else {
1092
1105
  dependent.blockedReason = `dependency_failed:${currentId}`;
@@ -1094,10 +1107,39 @@ function propagateDependencyFailure(meshId: string, failedTaskId: string): void
1094
1107
  }
1095
1108
  }
1096
1109
  }
1110
+ return cancelled;
1097
1111
  }
1098
1112
 
1099
1113
  const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
1100
1114
 
1115
+ /**
1116
+ * G3 (step ①) — fire-and-forget mission_close_candidate detection for the missions of
1117
+ * the given task ids. Called after any task-status mutation (completion / failure /
1118
+ * cancel / dependency-cascade / new-task enqueue) so a mission whose tasks all just
1119
+ * became terminal gets a one-shot "consider closing" nudge, and a mission that just
1120
+ * gained a non-terminal task has its idempotency marker reset.
1121
+ *
1122
+ * Loaded via a lazy dynamic import to break the static queue↔missions import cycle
1123
+ * (mesh-missions statically imports getQueue from here): the resolve happens off the
1124
+ * mutation's critical path, and any failure is swallowed — this is a best-effort hint,
1125
+ * never allowed to affect the task write that triggered it.
1126
+ */
1127
+ function scheduleMissionCloseCandidateCheck(meshId: string, entries: Array<MeshWorkQueueEntry | null | undefined>): void {
1128
+ const missionIds = new Set<string>();
1129
+ for (const entry of entries) {
1130
+ const missionId = entry?.missionId;
1131
+ if (typeof missionId === 'string' && missionId.trim()) missionIds.add(missionId.trim());
1132
+ }
1133
+ if (missionIds.size === 0) return;
1134
+ void import('./mesh-missions.js')
1135
+ .then(({ maybeEmitMissionCloseCandidate }) => {
1136
+ for (const missionId of missionIds) {
1137
+ try { maybeEmitMissionCloseCandidate(meshId, missionId); } catch { /* best-effort per mission */ }
1138
+ }
1139
+ })
1140
+ .catch(() => { /* best-effort: never break a task mutation on the hint path */ });
1141
+ }
1142
+
1101
1143
  /**
1102
1144
  * Update the status of a specific task.
1103
1145
  * Used when a session completes, fails, or stalls.
@@ -1109,7 +1151,7 @@ export function updateTaskStatus(
1109
1151
  opts?: MeshQueueMutationOptions,
1110
1152
  ): MeshWorkQueueEntry | null {
1111
1153
  requireMeshHostQueueOwner(opts);
1112
- return withQueueLock(meshId, () => {
1154
+ const result = withQueueLock(meshId, () => {
1113
1155
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1114
1156
  if (!entry) return null;
1115
1157
  entry.status = status;
@@ -1117,9 +1159,11 @@ export function updateTaskStatus(
1117
1159
  // Any transition OFF `assigned` ends the single-flight dispatch window (terminal
1118
1160
  // completion/failure, or the dispatch-failure requeue to `pending`).
1119
1161
  if (status !== 'assigned') endTaskDispatchInFlight(meshId, taskId);
1120
- if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, taskId);
1121
- return entry;
1162
+ const cascaded = DEPENDENCY_FAILURE_TERMINALS.has(status) ? propagateDependencyFailure(meshId, taskId) : [];
1163
+ return { entry, cascaded };
1122
1164
  });
1165
+ if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1166
+ return result ? result.entry : null;
1123
1167
  }
1124
1168
 
1125
1169
  export function recordTaskAutoLaunch(
@@ -1146,7 +1190,7 @@ export function cancelTask(
1146
1190
  opts?: { reason?: string } & MeshQueueMutationOptions,
1147
1191
  ): MeshWorkQueueEntry | null {
1148
1192
  requireMeshHostQueueOwner(opts);
1149
- return withQueueLock(meshId, () => {
1193
+ const result = withQueueLock(meshId, () => {
1150
1194
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1151
1195
  if (!entry) return null;
1152
1196
  const now = new Date().toISOString();
@@ -1155,9 +1199,11 @@ export function cancelTask(
1155
1199
  if (opts?.reason) entry.cancelReason = opts.reason;
1156
1200
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1157
1201
  endTaskDispatchInFlight(meshId, taskId);
1158
- propagateDependencyFailure(meshId, taskId);
1159
- return entry;
1202
+ const cascaded = propagateDependencyFailure(meshId, taskId);
1203
+ return { entry, cascaded };
1160
1204
  });
1205
+ if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1206
+ return result ? result.entry : null;
1161
1207
  }
1162
1208
 
1163
1209
  /**
@@ -1188,7 +1234,7 @@ export function requeueTask(
1188
1234
  } & MeshQueueMutationOptions,
1189
1235
  ): MeshWorkQueueEntry | null {
1190
1236
  requireMeshHostQueueOwner(opts);
1191
- return withQueueLock(meshId, () => {
1237
+ const result = withQueueLock(meshId, () => {
1192
1238
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1193
1239
  if (!entry) return null;
1194
1240
  // CANON-IDENTITY single-flight: refuse (no-op) to reopen a task whose dispatch
@@ -1203,7 +1249,8 @@ export function requeueTask(
1203
1249
  // from this single-flight guard; the exemption hook (group-id check) belongs here.
1204
1250
  if (!opts?.force && isTaskDispatchInFlight(meshId, taskId)) {
1205
1251
  LOG.warn('MeshQueue', `Refusing to requeue task ${taskId} on mesh ${meshId}: it is actively dispatched/generating (single-flight in-flight). Requeueing now would open a duplicate second dispatch into another session. Pass force to override.`);
1206
- return entry;
1252
+ // No status change → no mission aggregate change; nothing to re-check.
1253
+ return { entry, cascaded: [] as MeshWorkQueueEntry[], missionAffected: false };
1207
1254
  }
1208
1255
  // Proceeding to requeue (or force-override): the prior dispatch is being abandoned,
1209
1256
  // so end the single-flight window for this task id.
@@ -1216,8 +1263,9 @@ export function requeueTask(
1216
1263
  entry.cancelReason = `max_retries_exceeded: requeued ${currentCount} time(s), limit is ${maxRetries}`;
1217
1264
  entry.updatedAt = new Date().toISOString();
1218
1265
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1219
- propagateDependencyFailure(meshId, taskId);
1220
- return entry;
1266
+ const cascaded = propagateDependencyFailure(meshId, taskId);
1267
+ // Terminal (failed) → mission may now be all-terminal.
1268
+ return { entry, cascaded, missionAffected: true };
1221
1269
  }
1222
1270
  entry.status = 'pending';
1223
1271
  // Operator requeue clears a dependency-failure block — the operator is
@@ -1235,8 +1283,13 @@ export function requeueTask(
1235
1283
  entry.requeueCount = currentCount + 1;
1236
1284
  if (opts?.reason) entry.requeueReason = opts.reason;
1237
1285
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1238
- return entry;
1286
+ // Non-terminal (back to pending) → mission left the all-terminal state; the
1287
+ // close-candidate check resets any stale idempotency marker so a later
1288
+ // re-completion can nudge again.
1289
+ return { entry, cascaded: [] as MeshWorkQueueEntry[], missionAffected: true };
1239
1290
  });
1291
+ if (result?.missionAffected) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1292
+ return result ? result.entry : null;
1240
1293
  }
1241
1294
 
1242
1295
  /**
@@ -1269,7 +1322,7 @@ export function reclaimStrandedAssignedTask(
1269
1322
  opts?: { reason?: string; ageMs?: number } & MeshQueueMutationOptions,
1270
1323
  ): MeshWorkQueueEntry | null {
1271
1324
  requireMeshHostQueueOwner(opts);
1272
- return withQueueLock(meshId, () => {
1325
+ const result = withQueueLock(meshId, () => {
1273
1326
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1274
1327
  if (!entry) return null;
1275
1328
  // Only a still-assigned row is stranded. If a completion/cancel already moved it
@@ -1291,12 +1344,13 @@ export function reclaimStrandedAssignedTask(
1291
1344
  // The stranded assignment is being torn down (→ pending or failed); end its
1292
1345
  // single-flight window so a re-claim/requeue is not blocked.
1293
1346
  endTaskDispatchInFlight(meshId, taskId);
1347
+ let cascaded: MeshWorkQueueEntry[] = [];
1294
1348
  if (reclaims > MAX_STRANDED_RECLAIMS) {
1295
1349
  // Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
1296
1350
  entry.status = 'failed';
1297
1351
  entry.cancelReason = `stranded_dispatch_unrecovered: reclaimed ${reclaims - 1} time(s) without a confirmed dispatch`;
1298
1352
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1299
- propagateDependencyFailure(meshId, taskId);
1353
+ cascaded = propagateDependencyFailure(meshId, taskId);
1300
1354
  } else {
1301
1355
  entry.status = 'pending';
1302
1356
  entry.requeuedAt = now;
@@ -1317,8 +1371,12 @@ export function reclaimStrandedAssignedTask(
1317
1371
  },
1318
1372
  });
1319
1373
  } catch { /* ledger write is best-effort */ }
1320
- return entry;
1374
+ return { entry, cascaded };
1321
1375
  });
1376
+ // Reclaim toggles the mission aggregate either way: → failed may make it all-terminal;
1377
+ // → pending resets any stale close-candidate marker. Re-check in both outcomes.
1378
+ if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1379
+ return result ? result.entry : null;
1322
1380
  }
1323
1381
 
1324
1382
  /**
@@ -1330,7 +1388,7 @@ export function updateSessionTaskStatus(
1330
1388
  status: MeshTaskStatus,
1331
1389
  opts?: { occurredAt?: string; taskId?: string },
1332
1390
  ): MeshWorkQueueEntry | null {
1333
- return withQueueLock(meshId, () => {
1391
+ const result = withQueueLock(meshId, () => {
1334
1392
  const store = MeshRuntimeStore.getInstance();
1335
1393
  const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
1336
1394
  const entry = store.findAssignedBySession(meshId, sessionId, occurredAtIso, opts?.taskId);
@@ -1352,9 +1410,11 @@ export function updateSessionTaskStatus(
1352
1410
  // The worker reported a terminal/non-assigned outcome — the dispatch is over;
1353
1411
  // release the single-flight mark so the task id can be re-dispatched later.
1354
1412
  if (status !== 'assigned') endTaskDispatchInFlight(meshId, entry.id);
1355
- if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
1356
- return entry;
1413
+ const cascaded = DEPENDENCY_FAILURE_TERMINALS.has(status) ? propagateDependencyFailure(meshId, entry.id) : [];
1414
+ return { entry, cascaded };
1357
1415
  });
1416
+ if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1417
+ return result ? result.entry : null;
1358
1418
  }
1359
1419
 
1360
1420
  /**