@adhdev/daemon-core 0.9.82-rc.552 → 0.9.82-rc.554
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-adapter-types.d.ts +29 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +80 -1
- package/dist/cli-adapters/provider-cli-shared.d.ts +55 -0
- package/dist/cli-adapters/terminal-screen.d.ts +5 -0
- package/dist/commands/stream-commands.d.ts +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +789 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +785 -12
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-refine-gates.d.ts +86 -0
- package/dist/providers/cli-provider-instance.d.ts +97 -0
- package/dist/providers/provider-instance.d.ts +9 -0
- package/dist/providers/spec/adapter.d.ts +7 -0
- package/dist/providers/spec/cli-adapter.d.ts +64 -0
- package/dist/providers/spec/fsm-driver.d.ts +11 -0
- package/dist/repo-mesh-types.d.ts +23 -0
- package/package.json +3 -3
- package/src/cli-adapter-types.ts +29 -0
- package/src/cli-adapters/provider-cli-adapter.ts +135 -0
- package/src/cli-adapters/provider-cli-shared.ts +172 -0
- package/src/cli-adapters/terminal-screen.ts +5 -0
- package/src/commands/handler.ts +4 -0
- package/src/commands/router-refine.ts +40 -1
- package/src/commands/router.ts +15 -0
- package/src/commands/stream-commands.ts +125 -0
- package/src/index.ts +1 -0
- package/src/mesh/coordinator-prompt.ts +2 -0
- package/src/mesh/mesh-events-utils.ts +15 -0
- package/src/mesh/mesh-ledger.ts +6 -0
- package/src/mesh/mesh-refine-gates.ts +256 -0
- package/src/providers/cli-provider-instance.ts +277 -6
- package/src/providers/provider-instance-manager.ts +11 -0
- package/src/providers/provider-instance.ts +10 -0
- package/src/providers/spec/adapter.ts +7 -0
- package/src/providers/spec/cli-adapter.ts +122 -0
- package/src/providers/spec/fsm-driver.ts +5 -0
- package/src/repo-mesh-types.ts +35 -0
|
@@ -556,6 +556,262 @@ export async function runMeshRefinePatchEquivalenceGate(
|
|
|
556
556
|
}
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Machine-readable sub-classification of a `patch_equivalence_failed` (and the
|
|
561
|
+
* related submodule-gitlink preflight blocks). The opaque top-level
|
|
562
|
+
* `patch_equivalence_failed` code is preserved for backward compatibility; this
|
|
563
|
+
* detailed reason is added ALONGSIDE it so coordinators no longer have to guess
|
|
564
|
+
* WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
|
|
565
|
+
* the real cause was a diverged base + an unreachable submodule gitlink artifact,
|
|
566
|
+
* not a real patch conflict, but Refinery only returned the opaque code and the
|
|
567
|
+
* coordinator mis-attributed it to a stale daemon version).
|
|
568
|
+
*/
|
|
569
|
+
export type MeshRefinePatchEquivalenceDetailedReasonCode =
|
|
570
|
+
/** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
|
|
571
|
+
| 'base_divergence'
|
|
572
|
+
/** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
|
|
573
|
+
| 'submodule_unreachable'
|
|
574
|
+
/** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
|
|
575
|
+
| 'actual_patch_diff'
|
|
576
|
+
/** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
|
|
577
|
+
| 'trivial_ff_misjudgment'
|
|
578
|
+
/** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
|
|
579
|
+
| 'already_converged'
|
|
580
|
+
/** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
|
|
581
|
+
| 'unclassified';
|
|
582
|
+
|
|
583
|
+
export type MeshRefinePatchEquivalenceFailureClassification = {
|
|
584
|
+
detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
|
|
585
|
+
/** Human-readable one-line description of the sub-cause. */
|
|
586
|
+
detailedReasonDescription: string;
|
|
587
|
+
/** Suggested next action for the coordinator/owner (free-form, actionable). */
|
|
588
|
+
recommendedAction: string;
|
|
589
|
+
/** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
|
|
590
|
+
evidence: {
|
|
591
|
+
baseHead?: string;
|
|
592
|
+
branchHead?: string;
|
|
593
|
+
mergeBase?: string;
|
|
594
|
+
/** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
|
|
595
|
+
behind?: number;
|
|
596
|
+
/** How many commits the branch is ahead of the merge-base. */
|
|
597
|
+
ahead?: number;
|
|
598
|
+
/** True when HEAD is NOT a descendant of the target base (diverged). */
|
|
599
|
+
baseDiverged?: boolean;
|
|
600
|
+
expectedPatchId?: string;
|
|
601
|
+
actualPatchId?: string;
|
|
602
|
+
patchIdEqual?: boolean;
|
|
603
|
+
/** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
|
|
604
|
+
diffStat?: string;
|
|
605
|
+
/** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
|
|
606
|
+
submoduleGitlinks?: Array<{
|
|
607
|
+
path: string;
|
|
608
|
+
baseCommit?: string;
|
|
609
|
+
branchCommit?: string;
|
|
610
|
+
/** True when branchCommit descends baseCommit (a strict fast-forward advance). */
|
|
611
|
+
fastForward?: boolean;
|
|
612
|
+
/** True when branchCommit is reachable from the submodule's local origin/main. */
|
|
613
|
+
reachableFromOriginMain?: boolean;
|
|
614
|
+
}>;
|
|
615
|
+
/** Effective auto-publish-submodule-main-commits policy value at classification time. */
|
|
616
|
+
autoPublishSubmoduleMainCommits?: boolean;
|
|
617
|
+
/** Set when the classifier itself errored (detailedReason === 'unclassified'). */
|
|
618
|
+
classifierError?: string;
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Classify WHY a patch-equivalence preflight blocked, turning the opaque
|
|
624
|
+
* `patch_equivalence_failed` code into a machine-readable {@link
|
|
625
|
+
* MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
|
|
626
|
+
* structured evidence. Read-only: runs only `git` inspection commands (rev-list,
|
|
627
|
+
* merge-base, diff --stat, submodule reachability probes) against the already-set
|
|
628
|
+
* worktree — it never mutates the repo.
|
|
629
|
+
*
|
|
630
|
+
* Priority of classification (first match wins):
|
|
631
|
+
* 1. already_converged — ahead 0 & behind 0 & no residual diff
|
|
632
|
+
* 2. submodule_unreachable — a changed gitlink commit is not reachable from the
|
|
633
|
+
* submodule's origin/main (publish needed)
|
|
634
|
+
* 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
|
|
635
|
+
* patch-ids match — blocked only by a ff gitlink
|
|
636
|
+
* 4. base_divergence — HEAD is not a descendant of the target base
|
|
637
|
+
* 5. actual_patch_diff — genuine content divergence (the residual case)
|
|
638
|
+
*
|
|
639
|
+
* `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
|
|
640
|
+
* or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
|
|
641
|
+
* the submodule_unreachable recommendation can name the current policy value.
|
|
642
|
+
*/
|
|
643
|
+
export async function classifyPatchEquivalenceFailure(
|
|
644
|
+
repoRoot: string,
|
|
645
|
+
baseHead: string,
|
|
646
|
+
branchHead: string,
|
|
647
|
+
summary: MeshRefinePatchEquivalenceSummary,
|
|
648
|
+
options: { targetBaseRef?: string; autoPublishSubmoduleMainCommits?: boolean } = {},
|
|
649
|
+
): Promise<MeshRefinePatchEquivalenceFailureClassification> {
|
|
650
|
+
const targetBaseRef = options.targetBaseRef || baseHead;
|
|
651
|
+
const autoPublish = options.autoPublishSubmoduleMainCommits;
|
|
652
|
+
const evidence: MeshRefinePatchEquivalenceFailureClassification['evidence'] = {
|
|
653
|
+
baseHead,
|
|
654
|
+
branchHead,
|
|
655
|
+
mergeBase: summary.mergeBase,
|
|
656
|
+
expectedPatchId: summary.expectedPatchId,
|
|
657
|
+
actualPatchId: summary.actualPatchId,
|
|
658
|
+
patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
|
|
659
|
+
...(autoPublish !== undefined ? { autoPublishSubmoduleMainCommits: autoPublish } : {}),
|
|
660
|
+
};
|
|
661
|
+
try {
|
|
662
|
+
const git = (args: string[]): string => execFileSync(GIT, args, {
|
|
663
|
+
cwd: repoRoot,
|
|
664
|
+
encoding: 'utf8',
|
|
665
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
666
|
+
windowsHide: true,
|
|
667
|
+
});
|
|
668
|
+
const gitOk = (args: string[]): boolean => {
|
|
669
|
+
try { git(args); return true; } catch { return false; }
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// ahead/behind of branch vs the target base ref. left = base-only (behind),
|
|
673
|
+
// right = branch-only (ahead).
|
|
674
|
+
let ahead = 0;
|
|
675
|
+
let behind = 0;
|
|
676
|
+
try {
|
|
677
|
+
const out = git(['rev-list', '--left-right', '--count', `${targetBaseRef}...${branchHead}`]).trim();
|
|
678
|
+
const [left, right] = out.split(/\s+/).map(n => Number.parseInt(n, 10));
|
|
679
|
+
behind = Number.isFinite(left) ? left : 0;
|
|
680
|
+
ahead = Number.isFinite(right) ? right : 0;
|
|
681
|
+
} catch { /* keep zeros */ }
|
|
682
|
+
evidence.ahead = ahead;
|
|
683
|
+
evidence.behind = behind;
|
|
684
|
+
// HEAD (branchHead) diverged from the target base = base is NOT an ancestor
|
|
685
|
+
// of the branch. behind>0 with the base ref not reachable from HEAD.
|
|
686
|
+
const baseIsAncestor = gitOk(['merge-base', '--is-ancestor', targetBaseRef, branchHead]);
|
|
687
|
+
evidence.baseDiverged = !baseIsAncestor;
|
|
688
|
+
|
|
689
|
+
// Residual/actual diff stat (best-effort): what the merge would still introduce.
|
|
690
|
+
let diffStat = '';
|
|
691
|
+
try {
|
|
692
|
+
if (summary.mergedTree) {
|
|
693
|
+
diffStat = git(['diff', '--stat', baseHead, summary.mergedTree]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
694
|
+
} else {
|
|
695
|
+
diffStat = git(['diff', '--stat', baseHead, branchHead]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
696
|
+
}
|
|
697
|
+
} catch { /* diff stat is best-effort */ }
|
|
698
|
+
if (diffStat) evidence.diffStat = diffStat;
|
|
699
|
+
|
|
700
|
+
// Changed gitlink reachability against each submodule's local origin/main.
|
|
701
|
+
const submoduleGitlinks: NonNullable<MeshRefinePatchEquivalenceFailureClassification['evidence']['submoduleGitlinks']> = [];
|
|
702
|
+
try {
|
|
703
|
+
const nameStatus = git(['diff', '--name-only', '--diff-filter=d', baseHead, branchHead]).trim();
|
|
704
|
+
const changedPaths = nameStatus ? nameStatus.split('\n').map(p => p.trim()).filter(Boolean) : [];
|
|
705
|
+
for (const p of changedPaths) {
|
|
706
|
+
// Only submodule (gitlink, mode 160000) entries.
|
|
707
|
+
let baseCommit: string | undefined;
|
|
708
|
+
let branchCommit: string | undefined;
|
|
709
|
+
try {
|
|
710
|
+
const baseLs = git(['ls-tree', baseHead, '--', p]).trim();
|
|
711
|
+
const branchLs = git(['ls-tree', branchHead, '--', p]).trim();
|
|
712
|
+
const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
|
|
713
|
+
if (!isGitlink) continue;
|
|
714
|
+
baseCommit = baseLs.split(/\s+/)[2];
|
|
715
|
+
branchCommit = branchLs.split(/\s+/)[2];
|
|
716
|
+
} catch { continue; }
|
|
717
|
+
const submoduleRepo = pathJoin(repoRoot, p);
|
|
718
|
+
let fastForward: boolean | undefined;
|
|
719
|
+
let reachableFromOriginMain: boolean | undefined;
|
|
720
|
+
if (branchCommit) {
|
|
721
|
+
if (baseCommit) {
|
|
722
|
+
fastForward = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', baseCommit, branchCommit]);
|
|
723
|
+
}
|
|
724
|
+
reachableFromOriginMain = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', branchCommit, 'refs/remotes/origin/main']);
|
|
725
|
+
}
|
|
726
|
+
submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
|
|
727
|
+
}
|
|
728
|
+
} catch { /* submodule inspection is best-effort */ }
|
|
729
|
+
if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
|
|
730
|
+
|
|
731
|
+
// Existing gate signal: the merge-tree trivial-ff evaluation, if the gate
|
|
732
|
+
// captured it (a genuine non-trivial submodule conflict lands here too).
|
|
733
|
+
const gitlinkFf = summary.gitlinkTrivialFastForward;
|
|
734
|
+
|
|
735
|
+
// ── Classification (first match wins) ────────────────────────────────
|
|
736
|
+
const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === '');
|
|
737
|
+
|
|
738
|
+
// 1. already_converged: nothing ahead, nothing behind, no residual diff.
|
|
739
|
+
if (ahead === 0 && behind === 0 && noResidualDiff) {
|
|
740
|
+
return {
|
|
741
|
+
detailedReason: 'already_converged',
|
|
742
|
+
detailedReasonDescription: 'Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.',
|
|
743
|
+
recommendedAction: 'Treat as already converged — no merge needed. Verify with `git range-diff` / patch-id, then mark the branch merged (or clean up the worktree).',
|
|
744
|
+
evidence,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 2. submodule_unreachable: a changed gitlink is not reachable from the
|
|
749
|
+
// submodule's origin/main. This is the publish-needed artifact.
|
|
750
|
+
const unreachable = submoduleGitlinks.filter(g => g.reachableFromOriginMain === false);
|
|
751
|
+
if (unreachable.length > 0) {
|
|
752
|
+
const paths = unreachable.map(g => g.path).join(', ');
|
|
753
|
+
return {
|
|
754
|
+
detailedReason: 'submodule_unreachable',
|
|
755
|
+
detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
|
|
756
|
+
recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === undefined ? 'unknown' : autoPublish}).`,
|
|
757
|
+
evidence,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 3. trivial_ff_misjudgment: HEAD descends the target base AND the non-gitlink
|
|
762
|
+
// patch-ids are equal, so the ONLY thing blocking is a fast-forward gitlink
|
|
763
|
+
// that merge-tree refused. (Either the gate flagged an unresolved gitlink
|
|
764
|
+
// ff, or every changed gitlink is a proven ff.)
|
|
765
|
+
const changedGitlinks = submoduleGitlinks.length > 0;
|
|
766
|
+
const allGitlinksFf = changedGitlinks && submoduleGitlinks.every(g => g.fastForward === true);
|
|
767
|
+
const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some(g => g.fastForward);
|
|
768
|
+
if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
|
|
769
|
+
return {
|
|
770
|
+
detailedReason: 'trivial_ff_misjudgment',
|
|
771
|
+
detailedReasonDescription: 'HEAD descends the target base and the patch content matches; the block is a submodule gitlink trivial fast-forward that merge-tree refused, not a real divergence.',
|
|
772
|
+
recommendedAction: 'Converge via the strict fast-forward-only bypass (verify HEAD descends origin/main and patch-id equality, then merge --ff-only) instead of the refine gate.',
|
|
773
|
+
evidence,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 4. base_divergence: HEAD is not a descendant of the target base.
|
|
778
|
+
if (!baseIsAncestor) {
|
|
779
|
+
return {
|
|
780
|
+
detailedReason: 'base_divergence',
|
|
781
|
+
detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
|
|
782
|
+
recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
|
|
783
|
+
evidence,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 5. actual_patch_diff: genuine non-equivalent content.
|
|
788
|
+
return {
|
|
789
|
+
detailedReason: 'actual_patch_diff',
|
|
790
|
+
detailedReasonDescription: 'The merge introduces content not equivalent to the branch\'s cumulative patch (expected tree vs actual merge diff differ).',
|
|
791
|
+
recommendedAction: 'Manual review required — inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.',
|
|
792
|
+
evidence,
|
|
793
|
+
};
|
|
794
|
+
} catch (e: any) {
|
|
795
|
+
evidence.classifierError = e?.message || String(e);
|
|
796
|
+
return {
|
|
797
|
+
detailedReason: 'unclassified',
|
|
798
|
+
detailedReasonDescription: 'Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.',
|
|
799
|
+
recommendedAction: 'Inspect the refineStages and patchEquivalence summary manually to determine the cause.',
|
|
800
|
+
evidence,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Small helper: run a git command in `cwd` and return whether it exited 0. */
|
|
806
|
+
function execGitOk(cwd: string, args: string[]): boolean {
|
|
807
|
+
try {
|
|
808
|
+
execFileSync(GIT, args, { cwd, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
|
|
809
|
+
return true;
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
559
815
|
export type MeshWorktreePatchContainmentSummary = {
|
|
560
816
|
/** True only when merging worktreeHead into ref introduces no new patch. */
|
|
561
817
|
contained: boolean;
|
|
@@ -16,6 +16,7 @@ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type In
|
|
|
16
16
|
import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
|
|
17
17
|
import { shortHash } from '../system/hash.js';
|
|
18
18
|
import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
|
|
19
|
+
import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
|
|
19
20
|
import { createCliAdapter } from './spec/route.js';
|
|
20
21
|
import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
21
22
|
import { StatusMonitor } from './status-monitor.js';
|
|
@@ -252,6 +253,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
252
253
|
*/
|
|
253
254
|
private static readonly APPROVAL_RESUME_GRACE_MS = 18_000;
|
|
254
255
|
|
|
256
|
+
// MESH-STALL-WATCH (feature 1: STALL detection): how long a coordinator-spawned
|
|
257
|
+
// mesh worker's raw PTY output (lastOutputAt) may stay unchanged before the
|
|
258
|
+
// status-agnostic stall watchdog fires ONE informational monitor:no_progress
|
|
259
|
+
// event. Unlike the StatusMonitor no-progress watchdog (which only runs while a
|
|
260
|
+
// turn is generating), this observes pure screen stasis regardless of the
|
|
261
|
+
// reported status — a worker parked idle, wedged mid-turn, or spawned with no
|
|
262
|
+
// output at all. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec so
|
|
263
|
+
// the two watchdogs agree on the same "long interval" bound.
|
|
264
|
+
private static readonly MESH_WORKER_STALL_THRESHOLD_MS = 180_000;
|
|
265
|
+
|
|
255
266
|
private adapter: ProviderCliAdapter;
|
|
256
267
|
private context: InstanceContext | null = null;
|
|
257
268
|
private events: ProviderEvent[] = [];
|
|
@@ -265,6 +276,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
265
276
|
// first sets it; the other becomes a no-op.
|
|
266
277
|
private agentReadyEmitted = false;
|
|
267
278
|
private generatingStartedAt: number = 0;
|
|
279
|
+
// MESH-STALL-WATCH (feature 1): the lastOutputAt value the stall episode is
|
|
280
|
+
// currently armed against. A stall episode is "the raw PTY output has not
|
|
281
|
+
// advanced past this anchor". When the adapter has never emitted output
|
|
282
|
+
// (lastOutputAt === 0) the anchor is the spawn time (this.startedAt) so a
|
|
283
|
+
// worker that produced NOTHING is still caught. On any new output the anchor
|
|
284
|
+
// re-arms to the fresh lastOutputAt and meshStallEmittedForAnchor resets, so a
|
|
285
|
+
// single continuous stall fires AT MOST ONCE and a later stall re-arms cleanly.
|
|
286
|
+
// -1 = not yet initialised for this session.
|
|
287
|
+
private meshStallAnchorAt = -1;
|
|
288
|
+
private meshStallEmittedForAnchor = false;
|
|
268
289
|
// FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
|
|
269
290
|
// phase (→generating or →waiting_approval). The completedDebouncePending snapshots
|
|
270
291
|
// this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
|
|
@@ -1506,6 +1527,26 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1506
1527
|
return '';
|
|
1507
1528
|
}
|
|
1508
1529
|
|
|
1530
|
+
/**
|
|
1531
|
+
* NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
|
|
1532
|
+
* cached for the current turn (lastCompletionSummary), if any. The evidence
|
|
1533
|
+
* probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
|
|
1534
|
+
* provider (antigravity) the parsed screen and the native transcript can both
|
|
1535
|
+
* momentarily yield no in-turn final assistant at the exact instant the
|
|
1536
|
+
* completion gate fires — source='unavailable', missingEvidence=true — even
|
|
1537
|
+
* though a prior poll already read the real answer off native-history and cached
|
|
1538
|
+
* it here (the same value mesh_read_chat.summary shows). Consulting the cache at
|
|
1539
|
+
* emit time lets that already-secured summary count as evidence, so the completion
|
|
1540
|
+
* notification carries the answer instead of completion_diagnostic=missing_final_assistant
|
|
1541
|
+
* with an empty summary. Returns '' when the cache is empty or was reset by the
|
|
1542
|
+
* next turn (see lastCompletionSummary = null on onTurnStarted).
|
|
1543
|
+
*/
|
|
1544
|
+
private cachedCompletionSummaryContent(): string {
|
|
1545
|
+
const cached = this.lastCompletionSummary;
|
|
1546
|
+
const content = typeof cached?.content === 'string' ? cached.content.trim() : '';
|
|
1547
|
+
return content;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1509
1550
|
private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
|
|
1510
1551
|
// (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
|
|
1511
1552
|
// pure message-content check ("does the last visible bubble read as a finalized assistant
|
|
@@ -1658,12 +1699,39 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1658
1699
|
const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
|
|
1659
1700
|
const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
|
|
1660
1701
|
|
|
1702
|
+
// NOTIF Defect-B: when the live evidence probe momentarily yields no in-turn
|
|
1703
|
+
// final assistant (source='unavailable'/external-native with present=false) but
|
|
1704
|
+
// a prior poll already parsed and CACHED the real answer for this turn
|
|
1705
|
+
// (lastCompletionSummary — the same value mesh_read_chat.summary surfaces),
|
|
1706
|
+
// credit the cache as evidence. This flips finalAssistantPresent to true and
|
|
1707
|
+
// records the cached source so the completion notification carries
|
|
1708
|
+
// completion_diagnostic=present with the summary, instead of
|
|
1709
|
+
// missing_final_assistant with an empty payload. Only ever UPGRADES a
|
|
1710
|
+
// point-sample miss — a genuine present=true is unchanged, and an empty cache
|
|
1711
|
+
// leaves the missing-evidence diagnostic exactly as before.
|
|
1712
|
+
const cachedSummary = evidence.present ? '' : this.cachedCompletionSummaryContent();
|
|
1713
|
+
const creditedFromCache = !evidence.present && cachedSummary.length > 0;
|
|
1714
|
+
const finalAssistantPresent = evidence.present || creditedFromCache;
|
|
1715
|
+
const finalAssistantEvidenceSource = evidence.present
|
|
1716
|
+
? evidence.source
|
|
1717
|
+
: (creditedFromCache ? 'cached-summary' : evidence.source);
|
|
1718
|
+
// When the cached summary rescues the evidence, the turn is no longer
|
|
1719
|
+
// "missing final assistant" — clear that blockReason so isMissingFinalAssistant‑
|
|
1720
|
+
// Diagnostic()/isWeakCompletionEvidence() no longer flag it (both key off
|
|
1721
|
+
// blockReason='missing_final_assistant' independently of finalAssistantPresent)
|
|
1722
|
+
// and the coordinator log's formatCompletionMetadata reads
|
|
1723
|
+
// completion_diagnostic=present (empty blockReason → 'present'). The ORIGINAL
|
|
1724
|
+
// reason is preserved under originalBlockReason for diagnostics.
|
|
1725
|
+
const clearMissingBlock = creditedFromCache && args.blockReason === 'missing_final_assistant';
|
|
1726
|
+
const effectiveBlockReason = clearMissingBlock ? undefined : args.blockReason;
|
|
1727
|
+
|
|
1661
1728
|
return {
|
|
1662
1729
|
providerType: this.type,
|
|
1663
1730
|
sessionId: this.instanceId,
|
|
1664
1731
|
providerSessionId: this.providerSessionId || null,
|
|
1665
1732
|
workspace: this.workingDir,
|
|
1666
|
-
blockReason:
|
|
1733
|
+
...(effectiveBlockReason ? { blockReason: effectiveBlockReason } : {}),
|
|
1734
|
+
...(clearMissingBlock ? { originalBlockReason: args.blockReason } : {}),
|
|
1667
1735
|
emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
|
|
1668
1736
|
waitedMs: args.waitedMs,
|
|
1669
1737
|
maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
|
|
@@ -1671,8 +1739,9 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1671
1739
|
latestVisibleStatus: args.latestVisibleStatus,
|
|
1672
1740
|
parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
|
|
1673
1741
|
parseError: parseError || undefined,
|
|
1674
|
-
finalAssistantPresent
|
|
1675
|
-
|
|
1742
|
+
finalAssistantPresent,
|
|
1743
|
+
finalAssistantFromCachedSummary: !evidence.present && cachedSummary.length > 0,
|
|
1744
|
+
finalAssistantEvidenceSource,
|
|
1676
1745
|
visibleMessageCount: visibleMessages.length,
|
|
1677
1746
|
lastVisibleRole,
|
|
1678
1747
|
lastVisibleKind,
|
|
@@ -1966,6 +2035,188 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1966
2035
|
|| this.settings.meshNodeId || this.settings.launchedByCoordinator);
|
|
1967
2036
|
}
|
|
1968
2037
|
|
|
2038
|
+
/**
|
|
2039
|
+
* MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
|
|
2040
|
+
* CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
|
|
2041
|
+
* the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
|
|
2042
|
+
* only; no debug buffers / parser state / history; byte-bounded, bottom-tail
|
|
2043
|
+
* preserved).
|
|
2044
|
+
*
|
|
2045
|
+
* Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
|
|
2046
|
+
* command args / env / user data, so only a coordinator-spawned worker session
|
|
2047
|
+
* is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
|
|
2048
|
+
* (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
|
|
2049
|
+
* "delegated" gate, so the two together block cross-mesh access. Returns null
|
|
2050
|
+
* for a non-mesh session so the daemon command surfaces a clean refusal.
|
|
2051
|
+
*/
|
|
2052
|
+
getTerminalScreenSnapshot(maxBytes?: number): {
|
|
2053
|
+
text: string;
|
|
2054
|
+
cursor: { col: number; row: number };
|
|
2055
|
+
cols: number;
|
|
2056
|
+
rows: number;
|
|
2057
|
+
truncated: boolean;
|
|
2058
|
+
originalBytes: number;
|
|
2059
|
+
returnedBytes: number;
|
|
2060
|
+
hash: string;
|
|
2061
|
+
} | null {
|
|
2062
|
+
if (!this.isMeshWorkerSession()) return null;
|
|
2063
|
+
// Defensive: not every CliAdapter implementation exposes the raw-terminal
|
|
2064
|
+
// read (the surface is declared optional on CliAdapter). Returning null
|
|
2065
|
+
// for an adapter that lacks it surfaces a clean unsupported refusal at
|
|
2066
|
+
// the daemon command instead of "getTerminalScreenSnapshot is not a
|
|
2067
|
+
// function" — the failure mode that broke mesh_read_terminal on the
|
|
2068
|
+
// spec-driven path before SpecCliAdapter implemented it.
|
|
2069
|
+
if (typeof this.adapter.getTerminalScreenSnapshot !== 'function') return null;
|
|
2070
|
+
return this.adapter.getTerminalScreenSnapshot(maxBytes);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* MESH-SEND-KEYS (feature 3: key injection). Public entry for the
|
|
2075
|
+
* mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
|
|
2076
|
+
* key encoding + atomic write + submit-race recheck + modal fail-closed).
|
|
2077
|
+
*
|
|
2078
|
+
* Gated on isMeshWorkerSession(): PTY input into a worker is a
|
|
2079
|
+
* coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
|
|
2080
|
+
* node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
|
|
2081
|
+
* double gate (confirm_destructive + policy) and the audit ledger. Returns a
|
|
2082
|
+
* refusal object for a non-mesh session so the daemon command surfaces a clean
|
|
2083
|
+
* error (never silently writes to a non-worker PTY).
|
|
2084
|
+
*/
|
|
2085
|
+
async injectKeys(
|
|
2086
|
+
items: MeshSendKeyItem[],
|
|
2087
|
+
opts: { allowModalOverride?: boolean } = {},
|
|
2088
|
+
): Promise<
|
|
2089
|
+
| { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
|
|
2090
|
+
| { ok: false; refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported'; keys: MeshSendKeyName[]; hasDestructive: boolean }
|
|
2091
|
+
> {
|
|
2092
|
+
if (!this.isMeshWorkerSession()) {
|
|
2093
|
+
return { ok: false, refused: 'not_mesh_worker', keys: [], hasDestructive: false };
|
|
2094
|
+
}
|
|
2095
|
+
// Defensive: injectKeys is optional on CliAdapter. An adapter without it
|
|
2096
|
+
// yields a clean 'unsupported' refusal instead of throwing
|
|
2097
|
+
// "injectKeys is not a function" — the failure that broke mesh_send_keys
|
|
2098
|
+
// on the spec-driven path before SpecCliAdapter implemented it.
|
|
2099
|
+
if (typeof this.adapter.injectKeys !== 'function') {
|
|
2100
|
+
return { ok: false, refused: 'unsupported', keys: [], hasDestructive: false };
|
|
2101
|
+
}
|
|
2102
|
+
return this.adapter.injectKeys(items, opts);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
|
|
2107
|
+
* watchdog for coordinator-spawned mesh worker sessions. Driven by the
|
|
2108
|
+
* ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
|
|
2109
|
+
* ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
|
|
2110
|
+
* clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
|
|
2111
|
+
* live worker's screen has been byte-for-byte unchanged for
|
|
2112
|
+
* MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
|
|
2113
|
+
* monitor:no_progress event down the existing task_stalled ledger +
|
|
2114
|
+
* pendingCoordinatorEvent path.
|
|
2115
|
+
*
|
|
2116
|
+
* Deliberately status-agnostic: it does NOT read getStatus()'s reported status
|
|
2117
|
+
* (which would couple it to the generating-only StatusMonitor and the
|
|
2118
|
+
* idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
|
|
2119
|
+
* minutes; that is accepted and surfaced as an informational stall (NOT a
|
|
2120
|
+
* failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
|
|
2121
|
+
* called here, so status heartbeats never move the stall anchor.
|
|
2122
|
+
*
|
|
2123
|
+
* Anchoring: the episode arms against the current lastOutputAt; a worker that
|
|
2124
|
+
* has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
|
|
2125
|
+
* time) so a silent spawn is still caught. Any new output re-arms the anchor
|
|
2126
|
+
* and clears the emitted flag, so one continuous stall emits at most once and a
|
|
2127
|
+
* later stall re-arms cleanly.
|
|
2128
|
+
*/
|
|
2129
|
+
checkMeshWorkerStall(now: number = Date.now()): void {
|
|
2130
|
+
if (!this.isMeshWorkerSession()) {
|
|
2131
|
+
// Not (or no longer) a mesh worker — drop any armed episode so a session
|
|
2132
|
+
// whose mesh markers were detached does not carry a stale anchor.
|
|
2133
|
+
this.meshStallAnchorAt = -1;
|
|
2134
|
+
this.meshStallEmittedForAnchor = false;
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
// Defensive: not every CliAdapter implementation exposes isAlive() — the
|
|
2138
|
+
// spec-driven adapter (SpecCliAdapter, used by native-source providers like
|
|
2139
|
+
// antigravity-cli) historically had none, so an unguarded call threw
|
|
2140
|
+
// `this.adapter.isAlive is not a function` on EVERY 5s tick, disabling stall
|
|
2141
|
+
// detection for those sessions entirely. A missing method is treated as alive
|
|
2142
|
+
// (the session lifecycle drops the anchor via isMeshWorkerSession()/exit paths),
|
|
2143
|
+
// never as a throw. When present, a dead process drops the armed episode.
|
|
2144
|
+
if (typeof this.adapter.isAlive === 'function' && !this.adapter.isAlive()) {
|
|
2145
|
+
// Dead PTY: nothing to watch. agent:stopped covers the exit; re-arm on
|
|
2146
|
+
// the next live session so a restart starts a fresh episode.
|
|
2147
|
+
this.meshStallAnchorAt = -1;
|
|
2148
|
+
this.meshStallEmittedForAnchor = false;
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
let lastOutputAt: number;
|
|
2153
|
+
try {
|
|
2154
|
+
// allowParse:false — a cheap status read that must NOT trigger parsing
|
|
2155
|
+
// and must NOT mutate lastOutputAt (getState/getStatus never bump it).
|
|
2156
|
+
const status = this.adapter.getStatus({ allowParse: false }) as { lastOutputAt?: unknown };
|
|
2157
|
+
lastOutputAt = typeof status?.lastOutputAt === 'number' && Number.isFinite(status.lastOutputAt)
|
|
2158
|
+
? status.lastOutputAt
|
|
2159
|
+
: 0;
|
|
2160
|
+
} catch {
|
|
2161
|
+
return; // defensive: a failed status read just skips this tick
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// The anchor is the last raw output; before any output, the spawn time so a
|
|
2165
|
+
// silent worker is still caught.
|
|
2166
|
+
const anchor = lastOutputAt > 0 ? lastOutputAt : this.startedAt;
|
|
2167
|
+
|
|
2168
|
+
if (this.meshStallAnchorAt === -1) {
|
|
2169
|
+
// First observation this session — arm against the current anchor.
|
|
2170
|
+
this.meshStallAnchorAt = anchor;
|
|
2171
|
+
this.meshStallEmittedForAnchor = false;
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (anchor > this.meshStallAnchorAt) {
|
|
2176
|
+
// New output advanced the clock — re-arm the episode against it.
|
|
2177
|
+
this.meshStallAnchorAt = anchor;
|
|
2178
|
+
this.meshStallEmittedForAnchor = false;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
if (this.meshStallEmittedForAnchor) return; // already fired for this stall
|
|
2183
|
+
|
|
2184
|
+
const stalledMs = now - this.meshStallAnchorAt;
|
|
2185
|
+
if (stalledMs < CliProviderInstance.MESH_WORKER_STALL_THRESHOLD_MS) return;
|
|
2186
|
+
|
|
2187
|
+
this.meshStallEmittedForAnchor = true;
|
|
2188
|
+
|
|
2189
|
+
// observedStatus is surfaced as context only — deliberately NOT stamped as
|
|
2190
|
+
// the reconciliation-triggering `status` field (which would let
|
|
2191
|
+
// buildNoProgressCompletionReconciliation mistake an idle stall for a
|
|
2192
|
+
// completion). See mesh-events-stale.buildNoProgressCompletionReconciliation.
|
|
2193
|
+
let observedStatus = 'unknown';
|
|
2194
|
+
try {
|
|
2195
|
+
const s = (this.adapter.getStatus({ allowParse: false }) as { status?: unknown })?.status;
|
|
2196
|
+
if (typeof s === 'string' && s) observedStatus = s;
|
|
2197
|
+
} catch { /* best-effort context only */ }
|
|
2198
|
+
|
|
2199
|
+
if (this.isMeshWorkerSession()) {
|
|
2200
|
+
traceMeshEventStage('fired', this.meshTraceCtx('monitor:no_progress'), 'mesh_worker_stall_watchdog');
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
const stalledSec = Math.round(stalledMs / 1000);
|
|
2204
|
+
this.pushEvent({
|
|
2205
|
+
event: 'monitor:no_progress',
|
|
2206
|
+
agentKey: `${this.type}:cli`,
|
|
2207
|
+
elapsedSec: stalledSec,
|
|
2208
|
+
timestamp: now,
|
|
2209
|
+
// MESH-STALL-WATCH marker: buildMeshSystemMessage generalizes the
|
|
2210
|
+
// coordinator message (generating-specific → "PTY output unchanged")
|
|
2211
|
+
// when this is set, since this watchdog fires status-agnostically.
|
|
2212
|
+
meshWorkerStall: true,
|
|
2213
|
+
lastOutputAt: this.meshStallAnchorAt,
|
|
2214
|
+
stalledMs,
|
|
2215
|
+
observedStatus,
|
|
2216
|
+
taskId: this.completingTurnTaskId(),
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
|
|
1969
2220
|
/**
|
|
1970
2221
|
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
1971
2222
|
* persist before the in-progress settle gate is torn down. For a delegated
|
|
@@ -2359,9 +2610,15 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2359
2610
|
// delegated session's inbox preview blank — or, for a LOCAL worktree session,
|
|
2360
2611
|
// stuck on the dispatched user task. If the parser DID surface assistant text,
|
|
2361
2612
|
// prefer it; only fall back to '' when no assistant summary can be derived.
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2613
|
+
// NOTIF Defect-B: completionFinalSummary is a point-sample of native-history/
|
|
2614
|
+
// screen at THIS instant; on a native-source provider (antigravity) it can be
|
|
2615
|
+
// empty at the forced-emit instant even though a prior poll already cached the
|
|
2616
|
+
// real answer (lastCompletionSummary). Fall back to the cache so the notification
|
|
2617
|
+
// carries the summary that mesh_read_chat.summary already shows — consistent with
|
|
2618
|
+
// completionDiagnostic.finalAssistantPresent being credited from the same cache.
|
|
2619
|
+
finalSummary: (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt)
|
|
2620
|
+
|| this.cachedCompletionSummaryContent()
|
|
2621
|
+
|| (blockReason.startsWith('parsed_status:') ? '' : undefined)),
|
|
2365
2622
|
completionDiagnostic,
|
|
2366
2623
|
});
|
|
2367
2624
|
this.completedDebouncePending = null;
|
|
@@ -3527,6 +3784,20 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3527
3784
|
this.completedDebouncePending = null;
|
|
3528
3785
|
continue;
|
|
3529
3786
|
}
|
|
3787
|
+
// MESH-STALL-WATCH dedupe: for a coordinator-spawned mesh worker the
|
|
3788
|
+
// status-agnostic stall watchdog (checkMeshWorkerStall) owns the
|
|
3789
|
+
// monitor:no_progress alert. The StatusMonitor here fires its OWN
|
|
3790
|
+
// generating-only no-progress on the SAME 180s bound, which would
|
|
3791
|
+
// double-emit into the task_stalled ledger + coordinator inbox for the
|
|
3792
|
+
// one stall. Suppress the StatusMonitor's copy for mesh workers only —
|
|
3793
|
+
// the completion-reconciliation branch above (which turns a no-progress
|
|
3794
|
+
// WITH final-assistant into a real completion) still runs, so genuine
|
|
3795
|
+
// idle-reconciled completions are unaffected. Non-mesh sessions keep the
|
|
3796
|
+
// original StatusMonitor behavior untouched.
|
|
3797
|
+
if (me.type === 'monitor:no_progress' && this.isMeshWorkerSession()) {
|
|
3798
|
+
traceMeshEventDrop('mesh_worker_stall_watchdog_owns_no_progress', this.meshTraceCtx('monitor:no_progress'));
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3530
3801
|
this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
|
|
3531
3802
|
}
|
|
3532
3803
|
}
|
|
@@ -211,7 +211,18 @@ export class ProviderInstanceManager {
|
|
|
211
211
|
this.tickInterval = intervalMs || this.tickInterval;
|
|
212
212
|
|
|
213
213
|
this.tickTimer = setInterval(async () => {
|
|
214
|
+
const now = Date.now();
|
|
214
215
|
for (const [id, instance] of this.instances) {
|
|
216
|
+
// MESH-STALL-WATCH (feature 1: STALL detection): a status-agnostic
|
|
217
|
+
// watchdog for coordinator-spawned mesh worker sessions, driven by
|
|
218
|
+
// THIS existing 5s tick (no separate timer). Cheap (reads the
|
|
219
|
+
// adapter's raw-output clock only) and a no-op for non-mesh /
|
|
220
|
+
// non-CLI instances, so it runs before the per-instance onTick.
|
|
221
|
+
try {
|
|
222
|
+
instance.checkMeshWorkerStall?.(now);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
LOG.warn('InstanceMgr', `[InstanceManager] Mesh stall check failed for ${id}: ${(e as Error).message}`);
|
|
225
|
+
}
|
|
215
226
|
try {
|
|
216
227
|
await instance.onTick();
|
|
217
228
|
} catch (e) {
|
|
@@ -195,6 +195,16 @@ export interface ProviderInstance {
|
|
|
195
195
|
/** Tick — periodic status refresh (IDE: readChat, Extension: stream collection) */
|
|
196
196
|
onTick(): Promise<void>;
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
|
|
200
|
+
* watchdog for coordinator-spawned mesh worker sessions, invoked from the
|
|
201
|
+
* ProviderInstanceManager's existing tick loop (no separate timer). Fires ONE
|
|
202
|
+
* informational monitor:no_progress event when a live worker's raw PTY output
|
|
203
|
+
* has been unchanged past the stall threshold. Optional — only CLI instances
|
|
204
|
+
* (which own a PTY / lastOutputAt clock) implement it; a no-op elsewhere.
|
|
205
|
+
*/
|
|
206
|
+
checkMeshWorkerStall?(now?: number): void;
|
|
207
|
+
|
|
198
208
|
/** Return current status */
|
|
199
209
|
getState(): ProviderState;
|
|
200
210
|
|