@adhdev/daemon-core 1.0.18-rc.20 → 1.0.18-rc.3

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 (74) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +0 -33
  2. package/dist/commands/cli-manager.d.ts +0 -9
  3. package/dist/commands/upgrade-helper.d.ts +0 -1
  4. package/dist/commands/windows-atomic-upgrade.d.ts +0 -5
  5. package/dist/config/mesh-json-config.d.ts +0 -62
  6. package/dist/index.d.ts +2 -4
  7. package/dist/index.js +3998 -6112
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +3855 -5966
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/coordinator-prompt.d.ts +0 -76
  12. package/dist/mesh/mesh-active-work.d.ts +1 -1
  13. package/dist/mesh/mesh-ledger.d.ts +1 -28
  14. package/dist/mesh/mesh-node-identity.d.ts +0 -23
  15. package/dist/mesh/mesh-refine-gates.d.ts +0 -89
  16. package/dist/mesh/mesh-remote-event-pull.d.ts +0 -3
  17. package/dist/mesh/mesh-runtime-store.d.ts +0 -11
  18. package/dist/mesh/mesh-work-queue.d.ts +1 -24
  19. package/dist/providers/chat-message-normalization.d.ts +0 -22
  20. package/dist/providers/cli-provider-instance-types.d.ts +0 -4
  21. package/dist/providers/cli-provider-instance.d.ts +0 -78
  22. package/dist/providers/contracts.d.ts +0 -17
  23. package/dist/providers/provider-schema.d.ts +0 -1
  24. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +0 -14
  25. package/dist/providers/types/interactive-prompt.d.ts +0 -18
  26. package/dist/repo-mesh-types.d.ts +6 -38
  27. package/dist/shared-types.d.ts +2 -4
  28. package/package.json +3 -3
  29. package/src/boot/daemon-lifecycle.ts +0 -10
  30. package/src/cli-adapters/cli-state-engine.ts +1 -17
  31. package/src/cli-adapters/provider-cli-adapter.ts +6 -2
  32. package/src/cli-adapters/provider-cli-shared.ts +0 -48
  33. package/src/commands/cli-manager.ts +8 -72
  34. package/src/commands/high-family/mesh-coordinator-launch.ts +2 -17
  35. package/src/commands/high-family/mesh-events.ts +2 -13
  36. package/src/commands/med-family/mesh-crud.ts +0 -155
  37. package/src/commands/med-family/mesh-queue.ts +1 -74
  38. package/src/commands/router-refine.ts +4 -71
  39. package/src/commands/router.ts +0 -8
  40. package/src/commands/upgrade-helper.ts +15 -78
  41. package/src/commands/windows-atomic-upgrade.ts +40 -194
  42. package/src/config/mesh-json-config.ts +0 -111
  43. package/src/index.ts +1 -21
  44. package/src/mesh/coordinator-prompt.ts +11 -258
  45. package/src/mesh/mesh-active-work.ts +1 -11
  46. package/src/mesh/mesh-completion-synthesis.ts +1 -12
  47. package/src/mesh/mesh-event-classify.ts +0 -19
  48. package/src/mesh/mesh-event-forwarding.ts +6 -64
  49. package/src/mesh/mesh-events-utils.ts +0 -42
  50. package/src/mesh/mesh-ledger.ts +0 -77
  51. package/src/mesh/mesh-node-identity.ts +0 -49
  52. package/src/mesh/mesh-queue-assignment.ts +11 -210
  53. package/src/mesh/mesh-reconcile-loop.ts +3 -306
  54. package/src/mesh/mesh-refine-gates.ts +0 -300
  55. package/src/mesh/mesh-remote-event-pull.ts +47 -68
  56. package/src/mesh/mesh-runtime-store.ts +0 -21
  57. package/src/mesh/mesh-work-queue.ts +2 -85
  58. package/src/providers/chat-message-normalization.ts +0 -53
  59. package/src/providers/cli-provider-instance-types.ts +0 -53
  60. package/src/providers/cli-provider-instance.ts +15 -497
  61. package/src/providers/contracts.ts +1 -25
  62. package/src/providers/provider-schema.ts +0 -83
  63. package/src/providers/sdk/v1/builders/cli/detect-status.ts +0 -23
  64. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +0 -20
  65. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +0 -44
  66. package/src/providers/types/interactive-prompt.ts +0 -77
  67. package/src/repo-mesh-types.ts +12 -112
  68. package/src/shared-types.ts +1 -9
  69. package/src/status/reporter.ts +0 -1
  70. package/src/status/snapshot.ts +0 -2
  71. package/dist/mesh/mesh-disk-retention.d.ts +0 -105
  72. package/dist/providers/auto-approve-modes.d.ts +0 -14
  73. package/src/mesh/mesh-disk-retention.ts +0 -370
  74. package/src/providers/auto-approve-modes.ts +0 -103
@@ -1200,306 +1200,6 @@ export function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: strin
1200
1200
  });
1201
1201
  }
1202
1202
 
1203
- /**
1204
- * Whether both commits exist locally in the submodule repo AND neither is an
1205
- * ancestor of the other — i.e. a genuine sibling divergence off a shared merge
1206
- * base. `baseCommit` ancestor-of `branchCommit` (strict fast-forward) returns
1207
- * false: that case needs no rebase (the gate excludes it). Equal commits, a
1208
- * missing commit, or a non-submodule path all return false (nothing to converge
1209
- * / ambiguity stays "not divergent").
1210
- */
1211
- function isSubmoduleDivergedSibling(submoduleRepoPath: string, baseCommit: string, branchCommit: string): boolean {
1212
- if (!baseCommit || !branchCommit || baseCommit === branchCommit) return false;
1213
- try {
1214
- if (!fs.existsSync(submoduleRepoPath)) return false;
1215
- execFileSync(GIT, ['cat-file', '-e', `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1216
- execFileSync(GIT, ['cat-file', '-e', `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1217
- } catch {
1218
- return false; // one of the commits is not available locally — cannot judge divergence
1219
- }
1220
- // base ancestor-of branch ⇒ pure fast-forward, not a sibling divergence.
1221
- try {
1222
- execFileSync(GIT, ['merge-base', '--is-ancestor', baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1223
- return false;
1224
- } catch { /* not a fast-forward → keep checking */ }
1225
- // branch ancestor-of base ⇒ branch is strictly behind (base already contains
1226
- // it); no branch-side commits to replay, not our case.
1227
- try {
1228
- execFileSync(GIT, ['merge-base', '--is-ancestor', branchCommit, baseCommit], { cwd: submoduleRepoPath, stdio: 'ignore' });
1229
- return false;
1230
- } catch { /* neither ancestor of the other → genuine divergence */ }
1231
- // Require a real shared merge base so the rebase has a sane replay range.
1232
- try {
1233
- const mb = execFileSync(GIT, ['merge-base', baseCommit, branchCommit], { cwd: submoduleRepoPath, encoding: 'utf8' }).trim();
1234
- return !!mb;
1235
- } catch {
1236
- return false;
1237
- }
1238
- }
1239
-
1240
- export type SubmoduleGitlinkConvergeResult = {
1241
- /** True when at least one diverged gitlink submodule was rebased onto its base-side commit. */
1242
- converged: boolean;
1243
- /**
1244
- * Set when convergence was declined/aborted (fail-safe → caller keeps the
1245
- * original defer→blocked_review path). One of:
1246
- * no_changed_gitlinks — no gitlink differs base↔branch
1247
- * not_diverged — the gitlink is ff/behind/equal (gate handles it)
1248
- * rebase_conflict — replaying branch-side onto base-side hit a real
1249
- * content conflict inside the submodule (aborted)
1250
- */
1251
- reason?: string;
1252
- /**
1253
- * Converged gitlinks: path → the rebased submodule commit (SUBNEW) that the
1254
- * root rebase must resolve the gitlink conflict to. Only populated for paths
1255
- * whose submodule was successfully rebased (base-side is now a strict ancestor).
1256
- */
1257
- resolutions: Array<{ path: string; baseCommit: string; branchCommit: string; rebasedCommit: string }>;
1258
- /** Per-path outcome for observability/logging. */
1259
- gitlinks: Array<{
1260
- path: string;
1261
- baseCommit?: string;
1262
- branchCommit?: string;
1263
- rebasedCommit?: string;
1264
- action: 'rebased' | 'skipped_not_diverged' | 'rebase_conflict';
1265
- }>;
1266
- };
1267
-
1268
- /**
1269
- * Best-effort: ensure `commit` exists in the submodule repo at `submoduleRepoPath`
1270
- * by fetching it from the base repo's submodule checkout (a sibling working copy on
1271
- * the same machine) when it is missing. A no-op when the commit is already present
1272
- * or when the source path does not exist. Never throws — a fetch failure just
1273
- * leaves the commit missing and the caller's ancestry check declines to converge.
1274
- */
1275
- function ensureSubmoduleCommitLocal(submoduleRepoPath: string, baseSubmoduleRepoPath: string, commit: string): void {
1276
- if (!commit) return;
1277
- try {
1278
- execFileSync(GIT, ['cat-file', '-e', `${commit}^{commit}`], { cwd: submoduleRepoPath, stdio: 'ignore' });
1279
- return; // already present
1280
- } catch { /* missing → try to fetch it */ }
1281
- try {
1282
- if (!fs.existsSync(submoduleRepoPath) || !fs.existsSync(baseSubmoduleRepoPath)) return;
1283
- // Fetch the exact object from the base repo's submodule by absolute path. `file://`
1284
- // fetch of an explicit sha requires uploadpack.allowAnySHA1InWant on some gits;
1285
- // fetching the base submodule's HEAD/all refs is the portable way to bring the
1286
- // reachable object in, so fetch all branches and tags from the local path.
1287
- execFileSync(GIT, ['-c', 'protocol.file.allow=always', 'fetch', '-q', baseSubmoduleRepoPath, '+refs/heads/*:refs/adhdev-refine-base/*'], {
1288
- cwd: submoduleRepoPath,
1289
- stdio: ['ignore', 'ignore', 'pipe'],
1290
- });
1291
- } catch { /* best-effort; ancestry check will simply decline if still missing */ }
1292
- }
1293
-
1294
- /**
1295
- * STEP 1 of auto-converging diverged oss-style submodule gitlinks: rebase the
1296
- * branch-side submodule commit(s) onto the base-side submodule commit INSIDE the
1297
- * worktree's submodule checkout (detached HEAD), so the base-side commit becomes a
1298
- * strict ancestor of the rebased tip. Returns the rebased commit per path so the
1299
- * caller's root rebase can resolve the gitlink conflict to it (STEP 2, see
1300
- * {@link rootRebaseResolvingGitlinks}). This automates the documented manual
1301
- * strict-fast-forward bypass and keeps the landed submodule history linear rather
1302
- * than masking a divergence.
1303
- *
1304
- * Fail-safe by construction: it only touches gitlinks that are a genuine sibling
1305
- * divergence (both commits local, neither an ancestor of the other, shared merge
1306
- * base). A submodule rebase content conflict aborts, restores the branch-side
1307
- * checkout, and returns `converged:false` (caller keeps defer→blocked_review). It
1308
- * never commits the root and NEVER pushes — remote publish is the merge/
1309
- * reachability stage's job; this stage is local reconciliation only.
1310
- *
1311
- * @param worktreeRoot the branch worktree root (its `<path>` submodule checkout is rebased)
1312
- * @param baseRepoRoot the source/base repo root (reads the base-side gitlink commit)
1313
- * @param baseHead the fetched base head (root ref) — source of base-side gitlink commits
1314
- * @param branchHead the worktree branch head (root ref) — source of branch-side gitlink commits
1315
- */
1316
- export function convergeDivergedSubmoduleGitlinks(
1317
- worktreeRoot: string,
1318
- baseRepoRoot: string,
1319
- baseHead: string,
1320
- branchHead: string,
1321
- ): SubmoduleGitlinkConvergeResult {
1322
- const changed = readChangedGitlinkPaths(worktreeRoot, baseHead, branchHead);
1323
- if (changed.length === 0) {
1324
- return { converged: false, reason: 'no_changed_gitlinks', resolutions: [], gitlinks: [] };
1325
- }
1326
-
1327
- const gitlinks: SubmoduleGitlinkConvergeResult['gitlinks'] = [];
1328
- const resolutions: SubmoduleGitlinkConvergeResult['resolutions'] = [];
1329
- let sawDiverged = false;
1330
-
1331
- for (const path of changed) {
1332
- // Base-side commit comes from the base repo's tree; branch-side from the worktree's.
1333
- const baseCommit = readTreeObject(baseRepoRoot, baseHead, path);
1334
- const branchCommit = readTreeObject(worktreeRoot, branchHead, path);
1335
- const submoduleRepoPath = pathResolve(worktreeRoot, path);
1336
-
1337
- // The base-side submodule commit is recorded by the base workspace but may not
1338
- // yet exist in the worktree's submodule object store (it was committed locally
1339
- // in base/<path> and not necessarily fetched here). Make it available via a
1340
- // best-effort local fetch from the base repo's own submodule checkout so the
1341
- // ancestry check and rebase can see it. Without this, a genuinely-convergeable
1342
- // divergence would look "not local" and fall through to blocked_review.
1343
- if (baseCommit) {
1344
- ensureSubmoduleCommitLocal(submoduleRepoPath, pathResolve(baseRepoRoot, path), baseCommit);
1345
- }
1346
-
1347
- if (!baseCommit || !branchCommit || !isSubmoduleDivergedSibling(submoduleRepoPath, baseCommit, branchCommit)) {
1348
- gitlinks.push({ path, baseCommit, branchCommit, action: 'skipped_not_diverged' });
1349
- continue;
1350
- }
1351
- sawDiverged = true;
1352
-
1353
- // Rebase the branch-side submodule commit(s) onto the base-side commit in a
1354
- // DETACHED HEAD (never move a submodule branch ref). A conflict aborts and
1355
- // restores the submodule checkout to the branch-side commit.
1356
- let rebasedCommit: string | undefined;
1357
- try {
1358
- execFileSync(GIT, ['checkout', '-q', '--detach', branchCommit], { cwd: submoduleRepoPath, stdio: ['ignore', 'ignore', 'pipe'] });
1359
- execFileSync(GIT, ['rebase', baseCommit], { cwd: submoduleRepoPath, stdio: ['ignore', 'pipe', 'pipe'] });
1360
- rebasedCommit = execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: submoduleRepoPath, encoding: 'utf8' }).trim();
1361
- } catch {
1362
- try { execFileSync(GIT, ['rebase', '--abort'], { cwd: submoduleRepoPath, stdio: 'ignore' }); } catch { /* ignore */ }
1363
- try { execFileSync(GIT, ['checkout', '-q', '--detach', branchCommit], { cwd: submoduleRepoPath, stdio: 'ignore' }); } catch { /* ignore */ }
1364
- gitlinks.push({ path, baseCommit, branchCommit, action: 'rebase_conflict' });
1365
- // Real submodule content conflict → do NOT converge; caller keeps blocked_review.
1366
- return { converged: false, reason: 'rebase_conflict', resolutions: [], gitlinks };
1367
- }
1368
-
1369
- gitlinks.push({ path, baseCommit, branchCommit, rebasedCommit, action: 'rebased' });
1370
- resolutions.push({ path, baseCommit, branchCommit, rebasedCommit: rebasedCommit! });
1371
- }
1372
-
1373
- if (!sawDiverged) {
1374
- return { converged: false, reason: 'not_diverged', resolutions: [], gitlinks };
1375
- }
1376
- return { converged: resolutions.length > 0, resolutions, gitlinks };
1377
- }
1378
-
1379
- export type RootRebaseGitlinkResolveResult = {
1380
- /** True when the root rebase completed (with gitlink conflicts resolved to the converged commits). */
1381
- ok: boolean;
1382
- /** New root HEAD after the rebase (only meaningful when ok). */
1383
- branchHead?: string;
1384
- /**
1385
- * Set when the rebase was aborted (fail-safe). One of:
1386
- * non_gitlink_conflict — a conflict on a non-submodule path (genuine content conflict)
1387
- * unexpected_gitlink — a gitlink conflicted that we have no converged commit for
1388
- * rebase_error — the rebase failed for a non-conflict reason
1389
- */
1390
- reason?: string;
1391
- /** The paths that conflicted at the point of abort (for diagnostics). */
1392
- conflictPaths?: string[];
1393
- };
1394
-
1395
- /**
1396
- * STEP 2 of auto-converging diverged submodule gitlinks: rebase the worktree root
1397
- * branch onto `baseHead`, resolving each submodule-gitlink conflict to the
1398
- * pre-converged commit from {@link convergeDivergedSubmoduleGitlinks}. git's
1399
- * recursive merge refuses to auto-merge a diverged gitlink ("Recursive merging
1400
- * with submodules currently only supports trivial cases"), so we drive the rebase
1401
- * ourselves: on each stop, if the ONLY unmerged paths are gitlinks we have a
1402
- * converged commit for, we stage those to the converged commit and `--continue`.
1403
- * Any non-gitlink conflict (or a gitlink with no converged commit) aborts the
1404
- * rebase and returns `ok:false` → caller keeps the defer→blocked_review path.
1405
- *
1406
- * On success the base-side gitlink is a strict ancestor of the resolved branch-side
1407
- * commit, so the downstream patch-equivalence gate treats it as a trivial
1408
- * fast-forward and passes.
1409
- */
1410
- export function rootRebaseResolvingGitlinks(
1411
- worktreeRoot: string,
1412
- baseHead: string,
1413
- resolutions: Array<{ path: string; rebasedCommit: string }>,
1414
- ): RootRebaseGitlinkResolveResult {
1415
- const resolveByPath = new Map(resolutions.map(r => [r.path, r.rebasedCommit]));
1416
-
1417
- const runRebase = (args: string[]): { ok: boolean } => {
1418
- try {
1419
- execFileSync(GIT, args, {
1420
- cwd: worktreeRoot,
1421
- stdio: ['ignore', 'pipe', 'pipe'],
1422
- // A rebase editor prompt would hang; keep it non-interactive.
1423
- env: { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' },
1424
- });
1425
- return { ok: true };
1426
- } catch {
1427
- return { ok: false };
1428
- }
1429
- };
1430
-
1431
- const unmergedPaths = (): string[] => {
1432
- try {
1433
- return execFileSync(GIT, ['diff', '--name-only', '--diff-filter=U'], { cwd: worktreeRoot, encoding: 'utf8' })
1434
- .split('\n').map(s => s.trim()).filter(Boolean);
1435
- } catch {
1436
- return [];
1437
- }
1438
- };
1439
-
1440
- const abort = (reason: string, conflictPaths?: string[]): RootRebaseGitlinkResolveResult => {
1441
- try { execFileSync(GIT, ['rebase', '--abort'], { cwd: worktreeRoot, stdio: 'ignore' }); } catch { /* ignore */ }
1442
- return { ok: false, reason, conflictPaths };
1443
- };
1444
-
1445
- let progress = runRebase(['rebase', baseHead]);
1446
- let guard = 0;
1447
- while (!progress.ok) {
1448
- if (guard++ > 100) return abort('rebase_error');
1449
- const conflicts = unmergedPaths();
1450
- if (conflicts.length === 0) {
1451
- // Failed but no recorded conflicts — a non-conflict rebase error.
1452
- return abort('rebase_error');
1453
- }
1454
- // Every conflicting path must be a gitlink we have a converged commit for.
1455
- const unresolvable = conflicts.filter(p => !resolveByPath.has(p));
1456
- if (unresolvable.length > 0) {
1457
- // Distinguish a genuine (non-gitlink) content conflict from a gitlink we
1458
- // simply have no converged commit for. `ls-files --stage` reports mode
1459
- // 160000 for a gitlink at any conflict stage.
1460
- const unresolvableGitlink = unresolvable.some(p => {
1461
- try {
1462
- const staged = execFileSync(GIT, ['ls-files', '--stage', '--', p], { cwd: worktreeRoot, encoding: 'utf8' });
1463
- return /^160000\s/m.test(staged);
1464
- } catch {
1465
- return false;
1466
- }
1467
- });
1468
- const allGitlink = unresolvable.every(p => {
1469
- try {
1470
- const staged = execFileSync(GIT, ['ls-files', '--stage', '--', p], { cwd: worktreeRoot, encoding: 'utf8' });
1471
- return /^160000\s/m.test(staged);
1472
- } catch {
1473
- return false;
1474
- }
1475
- });
1476
- // A non-gitlink conflict is the fail-safe case that must clearly signal a
1477
- // genuine content conflict; a gitlink-only miss is the (rarer) case where a
1478
- // gitlink conflicted that STEP 1 did not converge.
1479
- return abort(allGitlink && unresolvableGitlink ? 'unexpected_gitlink' : 'non_gitlink_conflict', conflicts);
1480
- }
1481
- // Stage every conflicting gitlink to its converged commit, then continue.
1482
- for (const p of conflicts) {
1483
- const commit = resolveByPath.get(p)!;
1484
- try {
1485
- execFileSync(GIT, ['checkout', '-q', '--detach', commit], { cwd: pathResolve(worktreeRoot, p), stdio: 'ignore' });
1486
- } catch { /* the checkout is best-effort; the `add` below stamps the index either way */ }
1487
- try {
1488
- execFileSync(GIT, ['add', p], { cwd: worktreeRoot, stdio: 'ignore' });
1489
- } catch {
1490
- return abort('rebase_error', conflicts);
1491
- }
1492
- }
1493
- progress = runRebase(['rebase', '--continue']);
1494
- }
1495
-
1496
- let branchHead: string | undefined;
1497
- try {
1498
- branchHead = execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: worktreeRoot, encoding: 'utf8' }).trim();
1499
- } catch { /* leave undefined */ }
1500
- return { ok: true, branchHead };
1501
- }
1502
-
1503
1203
  /**
1504
1204
  * Decide whether a merge-tree submodule conflict between base and branch is a
1505
1205
  * trivial gitlink fast-forward (and nothing else).
@@ -60,78 +60,57 @@ export async function pullRemoteNodeQueues(
60
60
  // block the other nodes for the rest of the tick. Each node callback is fully
61
61
  // self-contained (local/candidate skip, peer-connected pre-check, per-candidate
62
62
  // pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
63
- await Promise.allSettled(mesh.nodes.map(node =>
64
- pullPendingEventsFromNode(components, meshId, node, localDaemonId, candidateDaemonIds, pulls)));
65
- }
66
-
67
- // Pull one node's pending coordinator events and re-inject them locally via
68
- // handleMeshForwardEvent (which runs the delivery-consume + re-queue paths). Factored
69
- // out of pullRemoteNodeQueues so the reconcile loop can also issue a TARGETED single-node
70
- // pull on demand specifically, the DELIVERED-NOT-CONSUMED redrive gate calls this for
71
- // one node right before it would re-drive, so a worker's already-emitted (but not-yet-
72
- // pulled) agent:generating_started is consumed IN-PROCESS this tick instead of waiting for
73
- // the next PHASE 1 pull. That closes the redrive-vs-consume race: the redrive gate reads
74
- // taskDeliveryConsumed() AFTER this pull has had its chance to flip the delivery row.
75
- // Returns silently on any skip/error — every caller treats it as best-effort.
76
- export async function pullPendingEventsFromNode(
77
- components: DaemonComponents,
78
- meshId: string,
79
- node: { daemonId?: string },
80
- localDaemonId: string | undefined,
81
- candidateDaemonIds: string[],
82
- pulls: Array<Record<string, unknown>>,
83
- ): Promise<void> {
84
- const dispatchMeshCommand = components.dispatchMeshCommand;
85
- if (!dispatchMeshCommand) return;
86
- const nodeDaemonId = readNonEmptyString(node.daemonId);
87
- // Skip nodes without a daemon, and nodes on THIS daemon (their events are
88
- // already in the local queue drained in PHASE 2). "This daemon" is matched
89
- // against the full self-identity set (candidateDaemonIds), not just the bare
90
- // localDaemonId — a self node can be registered under the config-form daemonId
91
- // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
92
- // from ourselves over P2P is both wasteful and a self-dispatch hazard.
93
- if (!nodeDaemonId) return;
94
- if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
95
- if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
96
-
97
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
98
- // a degraded peer whose DataChannel is not open would sink this pull into
99
- // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
100
- // the whole serial loop and delaying completion-event recovery from healthy
101
- // nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
102
- // unconnected peer has not drained anything (drained=0 preserved), so its events
103
- // are recovered whole on the next successful tick. Skip = delay, never loss.
104
- // • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
105
- // right now" = NOT connected (a powered-off node whose failPeer just deleted
106
- // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
107
- // dialing here would re-queue for another 90s (the null-race the guard is
108
- // meant to prevent). Only a snapshot with state === 'connected' proceeds.
109
- // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
110
- // so this stays regression-free (the standalone case the guard's history
111
- // references).
112
- const getPeerStatus = components.getMeshPeerConnectionStatus;
113
- if (getPeerStatus) {
114
- const peerSnapshot = getPeerStatus(nodeDaemonId);
115
- if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
116
- }
63
+ await Promise.allSettled(mesh.nodes.map(async (node) => {
64
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
65
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
66
+ // already in the local queue drained in PHASE 2). "This daemon" is matched
67
+ // against the full self-identity set (candidateDaemonIds), not just the bare
68
+ // localDaemonId a self node can be registered under the config-form daemonId
69
+ // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
70
+ // from ourselves over P2P is both wasteful and a self-dispatch hazard.
71
+ if (!nodeDaemonId) return;
72
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
73
+ if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
117
74
 
118
- for (const pendingEventArgs of pulls) {
119
- let events: unknown;
120
- try {
121
- events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
122
- } catch {
123
- // Remote pull is best-effort; the node may be offline. Retry next tick.
124
- break; // node unreachable don't bother with the other id form this tick.
75
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
76
+ // a degraded peer whose DataChannel is not open would sink this pull into
77
+ // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
78
+ // the whole serial loop and delaying completion-event recovery from healthy
79
+ // nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
80
+ // unconnected peer has not drained anything (drained=0 preserved), so its events
81
+ // are recovered whole on the next successful tick. Skip = delay, never loss.
82
+ // • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
83
+ // right now" = NOT connected (a powered-off node whose failPeer just deleted
84
+ // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
85
+ // dialing here would re-queue for another 90s (the null-race the guard is
86
+ // meant to prevent). Only a snapshot with state === 'connected' proceeds.
87
+ // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
88
+ // so this stays regression-free (the standalone case the guard's history
89
+ // references).
90
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
91
+ if (getPeerStatus) {
92
+ const peerSnapshot = getPeerStatus(nodeDaemonId);
93
+ if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
125
94
  }
126
- const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
127
- for (const event of list) {
128
- const payload = buildForwardPayloadFromPending(event);
129
- if (!payload.event || !payload.meshId) continue;
95
+
96
+ for (const pendingEventArgs of pulls) {
97
+ let events: unknown;
130
98
  try {
131
- handleMeshForwardEvent(components, payload);
132
- } catch { /* best-effort re-inject */ }
99
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
100
+ } catch {
101
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
102
+ break; // node unreachable — don't bother with the other id form this tick.
103
+ }
104
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
105
+ for (const event of list) {
106
+ const payload = buildForwardPayloadFromPending(event);
107
+ if (!payload.event || !payload.meshId) continue;
108
+ try {
109
+ handleMeshForwardEvent(components, payload);
110
+ } catch { /* best-effort re-inject */ }
111
+ }
133
112
  }
134
- }
113
+ }));
135
114
  }
136
115
 
137
116
  // Pull the read_chat payload out of whatever envelope the transport returned.
@@ -174,27 +174,6 @@ export class MeshRuntimeStore {
174
174
  this.instance = undefined;
175
175
  }
176
176
 
177
- /**
178
- * VACUUM the SQLite database to reclaim on-disk space. Retention prunes rows
179
- * with DELETE, which frees pages inside the file but does NOT shrink it — the
180
- * mesh-runtime.db grew to hundreds of MB (mission 86def38d disk-accumulation
181
- * bootstrap failure) precisely because the file was never compacted. This
182
- * rewrites the DB into a minimal footprint. Best-effort: a VACUUM failure (e.g.
183
- * insufficient temp space, a read lock) is logged and swallowed so it can never
184
- * block daemon shutdown. Called once on shutdown (see daemon-lifecycle), never
185
- * on the hot path — VACUUM takes an exclusive lock and rewrites the whole file.
186
- */
187
- vacuum(): void {
188
- try {
189
- // Fold the WAL back into the main DB first so VACUUM reclaims those pages too.
190
- try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* checkpoint best-effort */ }
191
- this.db.exec('VACUUM;');
192
- LOG.info('MeshRuntimeStore', 'VACUUM completed on shutdown');
193
- } catch (err: any) {
194
- LOG.warn('MeshRuntimeStore', `VACUUM on shutdown failed (ignored): ${err?.message || err}`);
195
- }
196
- }
197
-
198
177
  close(): void {
199
178
  this.db.close();
200
179
  }
@@ -1231,19 +1231,6 @@ function propagateDependencyFailure(meshId: string, failedTaskId: string): MeshW
1231
1231
 
1232
1232
  const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
1233
1233
 
1234
- /**
1235
- * CANCEL-STICKY-TERMINAL: the terminal task statuses. A row in one of these states is a
1236
- * historical record — no live dispatch owns it — and must NEVER be flipped back to an
1237
- * active (`pending`/`assigned`) state by a late writer. The canonical live example is a
1238
- * cancel that races the dispatch-failure `.catch` (mesh-queue-assignment.ts): that catch
1239
- * fires-and-forgets an unconditional `updateTaskStatus(...,'pending')`, resolving AFTER
1240
- * the cancel commits, which resurrected the cancelled row → it got re-claimed and the
1241
- * reclaim watchdog re-drove the same prompt. Guarding the write side (see
1242
- * {@link updateTaskStatus}) applies the same terminal-row protection
1243
- * {@link reclaimStrandedAssignedTask} already enforces to EVERY status writer at once.
1244
- */
1245
- const TERMINAL_TASK_STATUSES = new Set<MeshTaskStatus>(['completed', 'failed', 'cancelled']);
1246
-
1247
1234
  /**
1248
1235
  * G3 (step ①) — fire-and-forget mission_close_candidate detection for the missions of
1249
1236
  * the given task ids. Called after any task-status mutation (completion / failure /
@@ -1280,33 +1267,12 @@ export function updateTaskStatus(
1280
1267
  meshId: string,
1281
1268
  taskId: string,
1282
1269
  status: MeshTaskStatus,
1283
- opts?: {
1284
- /**
1285
- * CANCEL-STICKY-TERMINAL: operator/system override to permit a terminal→non-terminal
1286
- * transition (e.g. an explicit operator reopen). Without this, a write that would flip
1287
- * a `completed`/`failed`/`cancelled` row back to `pending`/`assigned` is refused as a
1288
- * no-op. Terminal→terminal and any transition FROM a non-terminal state are unaffected.
1289
- */
1290
- force?: boolean;
1291
- } & MeshQueueMutationOptions,
1270
+ opts?: MeshQueueMutationOptions,
1292
1271
  ): MeshWorkQueueEntry | null {
1293
1272
  requireMeshHostQueueOwner(opts);
1294
1273
  const result = withQueueLock(meshId, () => {
1295
1274
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1296
1275
  if (!entry) return null;
1297
- // CANCEL-STICKY-TERMINAL: never resurrect a terminal row into an active state. A late
1298
- // fire-and-forget writer (canonically the dispatch-failure `.catch` requeue to
1299
- // 'pending' in mesh-queue-assignment.ts, which resolves AFTER a cancel commits) must
1300
- // not undo a cancel/completion/failure — that revival let the row be re-claimed and the
1301
- // reclaim watchdog re-drive the same prompt. Refuse the transition as a no-op unless an
1302
- // explicit operator override is passed. This is the write-side sibling of the
1303
- // status!=='assigned' guard reclaimStrandedAssignedTask already applies.
1304
- if (!opts?.force
1305
- && TERMINAL_TASK_STATUSES.has(entry.status)
1306
- && !TERMINAL_TASK_STATUSES.has(status)) {
1307
- LOG.debug('MeshQueue', `Refusing updateTaskStatus(${taskId} → ${status}) on mesh ${meshId}: row is terminal (${entry.status}). A late writer (e.g. dispatch-failure requeue) must not resurrect a cancelled/completed/failed task. Pass force to override.`);
1308
- return { entry, cascaded: [] as MeshWorkQueueEntry[] };
1309
- }
1310
1276
  entry.status = status;
1311
1277
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1312
1278
  // Any transition OFF `assigned` ends the single-flight dispatch window (terminal
@@ -1347,67 +1313,18 @@ export function cancelTask(
1347
1313
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1348
1314
  if (!entry) return null;
1349
1315
  const now = new Date().toISOString();
1350
- // CANCEL-STICKY-TERMINAL (authoritative cancel): capture the prior assignment BEFORE
1351
- // clearing it, so the caller can stop the bound live worker. Leaving assignedNodeId/
1352
- // SessionId/ProviderType on the cancelled row let the still-running worker keep emitting
1353
- // delivery/turn signals that re-ignited the reclaim watchdog (observed: nonce 6→9,
1354
- // needing two cancels + a manual session stop). Clearing them also drops this row from
1355
- // the status==='assigned' counters so it can never be treated as live again.
1356
- const priorAssignment: CancelledTaskAssignment | undefined = entry.assignedSessionId
1357
- ? {
1358
- sessionId: entry.assignedSessionId,
1359
- nodeId: entry.assignedNodeId,
1360
- providerType: entry.assignedProviderType,
1361
- }
1362
- : undefined;
1363
1316
  entry.status = 'cancelled';
1364
1317
  entry.cancelledAt = now;
1365
1318
  if (opts?.reason) entry.cancelReason = opts.reason;
1366
- delete entry.assignedNodeId;
1367
- delete entry.assignedSessionId;
1368
- delete entry.assignedProviderType;
1369
- delete entry.dispatchTimestamp;
1370
- // Belt-and-suspenders: bump the dispatch nonce so any in-flight inject the
1371
- // now-orphaned worker later echoes carries a stale nonce and is rejected by the
1372
- // coordinator's stale-nonce guard — same mechanism reclaimStrandedAssignedTask uses.
1373
- entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
1374
1319
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1375
1320
  endTaskDispatchInFlight(meshId, taskId);
1376
1321
  const cascaded = propagateDependencyFailure(meshId, taskId);
1377
- return { entry, cascaded, priorAssignment };
1322
+ return { entry, cascaded };
1378
1323
  });
1379
1324
  if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1380
- // Surface the prior binding to the caller (out-of-band from the persisted row, so it is
1381
- // never serialized) so the cancel command handler — which holds DaemonComponents — can
1382
- // stop the now-orphaned worker via the transport-aware stopStaleMeshWorker helper.
1383
- if (result?.priorAssignment) lastCancelledTaskAssignment.set(`${meshId}::${taskId}`, result.priorAssignment);
1384
1325
  return result ? result.entry : null;
1385
1326
  }
1386
1327
 
1387
- /**
1388
- * CANCEL-STICKY-TERMINAL: the assignment a task carried at cancel time, handed to the cancel
1389
- * command handler so it can stop the bound worker. cancelTask runs in the pure queue-store
1390
- * module (no DaemonComponents), so it records the binding here and the handler drains it.
1391
- */
1392
- export interface CancelledTaskAssignment {
1393
- sessionId: string;
1394
- nodeId?: string;
1395
- providerType?: string;
1396
- }
1397
-
1398
- const lastCancelledTaskAssignment = new Map<string, CancelledTaskAssignment>();
1399
-
1400
- /**
1401
- * Read-and-clear the assignment a just-cancelled task was bound to. Returns undefined when the
1402
- * cancelled task had no live assignment (nothing to stop). One-shot: the entry is deleted on read.
1403
- */
1404
- export function takeCancelledTaskAssignment(meshId: string, taskId: string): CancelledTaskAssignment | undefined {
1405
- const key = `${meshId}::${taskId}`;
1406
- const value = lastCancelledTaskAssignment.get(key);
1407
- if (value) lastCancelledTaskAssignment.delete(key);
1408
- return value;
1409
- }
1410
-
1411
1328
  /**
1412
1329
  * Return a queue task to pending for retry. By default, dead session targeting
1413
1330
  * and assigned ownership are cleared so stale assignments do not strand again.
@@ -156,59 +156,6 @@ export function selectFinalAssistantTurnEndMessage(
156
156
  return null;
157
157
  }
158
158
 
159
- /**
160
- * EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
161
- *
162
- * True when the transcript's LAST non-empty user-facing assistant bubble is followed
163
- * by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
164
- * fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
165
- * then started running Read/Grep, so the turn is still executing and its answer has
166
- * not landed. Callers that would otherwise promote that preamble to a turn-end summary
167
- * off a momentary (startup-grace / inter-tool) idle read use this as a veto.
168
- *
169
- * Deliberately NARROW so the pure-PTY completion rescue is preserved:
170
- * - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
171
- * status bubble does NOT (a finished turn can end on an internal thought), matching
172
- * selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
173
- * - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
174
- * the kimi pure-PTY continuous-idle shape) returns false, so its early completion
175
- * is untouched.
176
- *
177
- * Returns false when there is no final assistant bubble at all (the caller's
178
- * selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
179
- */
180
- export function hasTrailingToolActivityAfterFinalAssistant(
181
- messages: ChatMessage[] | null | undefined,
182
- ): boolean {
183
- if (!Array.isArray(messages) || messages.length === 0) return false;
184
- let sawTrailingToolActivity = false;
185
- for (let i = messages.length - 1; i >= 0; i--) {
186
- const msg = messages[i];
187
- if (!msg) continue;
188
- const classification = classifyChatMessageVisibility(msg);
189
- // A trailing user-facing assistant/model bubble with real text ends the scan:
190
- // whether we saw a tool AFTER it is the verdict.
191
- if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
192
- if (flattenContent(msg.content).trim()) return sawTrailingToolActivity;
193
- // Empty streaming assistant bubble — keep scanning back past it, same as
194
- // selectFinalAssistantTurnEndMessage (which returns null here); an empty tail
195
- // isn't a turn end, so there is nothing to veto.
196
- return false;
197
- }
198
- if (classification.isUserFacing) {
199
- // A trailing user bubble (freshly dispatched task, no reply) → no assistant
200
- // turn end below it to veto.
201
- return false;
202
- }
203
- // Non-user-facing activity/internal bubble sitting AFTER the (not-yet-seen)
204
- // assistant bubble. Only tool/terminal activity signals an in-flight turn.
205
- if (classification.kind === 'tool' || classification.kind === 'terminal') {
206
- sawTrailingToolActivity = true;
207
- }
208
- }
209
- return false;
210
- }
211
-
212
159
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
213
160
 
214
161
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];