@adhdev/daemon-core 1.0.18-rc.20 → 1.0.18-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-adapters/provider-cli-shared.d.ts +0 -33
- package/dist/commands/cli-manager.d.ts +0 -9
- package/dist/commands/upgrade-helper.d.ts +0 -1
- package/dist/commands/windows-atomic-upgrade.d.ts +0 -5
- package/dist/config/mesh-json-config.d.ts +0 -62
- package/dist/index.d.ts +2 -4
- package/dist/index.js +3998 -6112
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3855 -5966
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +0 -76
- package/dist/mesh/mesh-active-work.d.ts +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -28
- package/dist/mesh/mesh-node-identity.d.ts +0 -23
- package/dist/mesh/mesh-refine-gates.d.ts +0 -89
- package/dist/mesh/mesh-remote-event-pull.d.ts +0 -3
- package/dist/mesh/mesh-runtime-store.d.ts +0 -11
- package/dist/mesh/mesh-work-queue.d.ts +1 -24
- package/dist/providers/chat-message-normalization.d.ts +0 -22
- package/dist/providers/cli-provider-instance-types.d.ts +0 -4
- package/dist/providers/cli-provider-instance.d.ts +0 -78
- package/dist/providers/contracts.d.ts +0 -17
- package/dist/providers/provider-schema.d.ts +0 -1
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +0 -14
- package/dist/providers/types/interactive-prompt.d.ts +0 -18
- package/dist/repo-mesh-types.d.ts +6 -38
- package/dist/shared-types.d.ts +2 -4
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +0 -10
- package/src/cli-adapters/cli-state-engine.ts +1 -17
- package/src/cli-adapters/provider-cli-adapter.ts +6 -2
- package/src/cli-adapters/provider-cli-shared.ts +0 -48
- package/src/commands/cli-manager.ts +8 -72
- package/src/commands/high-family/mesh-coordinator-launch.ts +2 -17
- package/src/commands/high-family/mesh-events.ts +2 -13
- package/src/commands/med-family/mesh-crud.ts +0 -155
- package/src/commands/med-family/mesh-queue.ts +1 -74
- package/src/commands/router-refine.ts +4 -71
- package/src/commands/router.ts +0 -8
- package/src/commands/upgrade-helper.ts +15 -78
- package/src/commands/windows-atomic-upgrade.ts +40 -194
- package/src/config/mesh-json-config.ts +0 -111
- package/src/index.ts +1 -21
- package/src/mesh/coordinator-prompt.ts +11 -258
- package/src/mesh/mesh-active-work.ts +1 -11
- package/src/mesh/mesh-completion-synthesis.ts +1 -12
- package/src/mesh/mesh-event-classify.ts +0 -19
- package/src/mesh/mesh-event-forwarding.ts +6 -64
- package/src/mesh/mesh-events-utils.ts +0 -42
- package/src/mesh/mesh-ledger.ts +0 -77
- package/src/mesh/mesh-node-identity.ts +0 -49
- package/src/mesh/mesh-queue-assignment.ts +11 -210
- package/src/mesh/mesh-reconcile-loop.ts +3 -306
- package/src/mesh/mesh-refine-gates.ts +0 -300
- package/src/mesh/mesh-remote-event-pull.ts +47 -68
- package/src/mesh/mesh-runtime-store.ts +0 -21
- package/src/mesh/mesh-work-queue.ts +2 -85
- package/src/providers/chat-message-normalization.ts +0 -53
- package/src/providers/cli-provider-instance-types.ts +0 -53
- package/src/providers/cli-provider-instance.ts +15 -497
- package/src/providers/contracts.ts +1 -25
- package/src/providers/provider-schema.ts +0 -83
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +0 -23
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +0 -20
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +0 -44
- package/src/providers/types/interactive-prompt.ts +0 -77
- package/src/repo-mesh-types.ts +12 -112
- package/src/shared-types.ts +1 -9
- package/src/status/reporter.ts +0 -1
- package/src/status/snapshot.ts +0 -2
- package/dist/mesh/mesh-disk-retention.d.ts +0 -105
- package/dist/providers/auto-approve-modes.d.ts +0 -14
- package/src/mesh/mesh-disk-retention.ts +0 -370
- 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
for (const
|
|
128
|
-
|
|
129
|
-
if (!payload.event || !payload.meshId) continue;
|
|
95
|
+
|
|
96
|
+
for (const pendingEventArgs of pulls) {
|
|
97
|
+
let events: unknown;
|
|
130
98
|
try {
|
|
131
|
-
|
|
132
|
-
} catch {
|
|
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
|
|
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];
|