@adhdev/daemon-core 0.9.82-rc.327 → 0.9.82-rc.329
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 +1 -1
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-status.d.ts +14 -0
- package/dist/git/index.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1140 -607
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1090 -564
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +66 -0
- package/dist/mesh/mesh-events-stale.d.ts +1 -1
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/shared-types.d.ts +1 -1
- package/package.json +2 -2
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +2 -2
- package/src/cli-adapters/provider-cli-shared.ts +1 -1
- package/src/commands/chat-commands.ts +1 -1
- package/src/commands/cli-manager.ts +1 -1
- package/src/commands/router.ts +46 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-status.ts +182 -16
- package/src/git/index.ts +16 -0
- package/src/index.ts +2 -2
- package/src/mesh/mesh-active-work.ts +154 -0
- package/src/mesh/mesh-events-coordinator.ts +13 -12
- package/src/mesh/mesh-events-stale.ts +4 -4
- package/src/mesh/mesh-events-utils.ts +3 -3
- package/src/mesh/mesh-reconcile-loop.ts +144 -1
- package/src/providers/acp-provider-instance.ts +6 -5
- package/src/providers/cli-provider-instance.ts +14 -10
- package/src/providers/extension-provider-instance.ts +7 -6
- package/src/providers/ide-provider-instance.ts +10 -6
- package/src/providers/read-chat-contract.ts +1 -1
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/shared-types.ts +3 -0
- package/src/status/reporter.ts +1 -1
|
@@ -55,7 +55,9 @@ import {
|
|
|
55
55
|
expireStaleUnresolvedDelegateForwards,
|
|
56
56
|
} from './mesh-unresolved-forward-outbox.js';
|
|
57
57
|
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
58
|
-
import { getActiveDirectDispatches } from './mesh-work-queue.js';
|
|
58
|
+
import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
|
|
59
|
+
import { readLedgerEntries } from './mesh-ledger.js';
|
|
60
|
+
import { pruneStaleDirectDispatches } from './mesh-active-work.js';
|
|
59
61
|
import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
|
|
60
62
|
import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
|
|
61
63
|
import type { ChatMessage } from '../types.js';
|
|
@@ -64,6 +66,25 @@ import type { ChatMessage } from '../types.js';
|
|
|
64
66
|
// coordinator land within at most one interval. Overridable via env for tuning.
|
|
65
67
|
const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
|
|
66
68
|
|
|
69
|
+
// PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
|
|
70
|
+
// orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
|
|
71
|
+
// measured from its dispatch time. This protects against a node/session that is only
|
|
72
|
+
// *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
|
|
73
|
+
// instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
|
|
74
|
+
// operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
|
|
75
|
+
const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
|
|
76
|
+
|
|
77
|
+
function resolveAutoPruneMinAgeMs(): number {
|
|
78
|
+
const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
|
|
79
|
+
if (raw) {
|
|
80
|
+
const parsed = Number.parseInt(raw, 10);
|
|
81
|
+
// Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
|
|
82
|
+
// (prune the moment something blinks) or effectively disable it forever.
|
|
83
|
+
if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
|
|
84
|
+
}
|
|
85
|
+
return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
function resolveReconcileIntervalMs(): number {
|
|
68
89
|
const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
|
|
69
90
|
if (raw) {
|
|
@@ -310,6 +331,36 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
310
331
|
}
|
|
311
332
|
}
|
|
312
333
|
|
|
334
|
+
// ── PHASE 5: auto-prune orphaned direct dispatch records ───────────────────
|
|
335
|
+
// staleDirectWork (orphaned direct-dispatch rows whose node/session is no longer in the
|
|
336
|
+
// live mesh) otherwise accumulates indefinitely: a removed worktree node or a cleanly
|
|
337
|
+
// terminated session leaves its direct-dispatch row behind, stuck in a non-terminal status
|
|
338
|
+
// (e.g. generating) for days. This is NOT a false-idle bug — it is the separate problem of
|
|
339
|
+
// orphaned records that the only existing cleanup path (manual MCP mesh_prune_stale_direct)
|
|
340
|
+
// never reaches unless an operator runs it by hand.
|
|
341
|
+
//
|
|
342
|
+
// This phase runs the SAME prune core the manual tool calls (pruneStaleDirectDispatches),
|
|
343
|
+
// in execute mode, on the daemon timer. The only difference from the manual path is a
|
|
344
|
+
// conservative age gate (DEFAULT_AUTO_PRUNE_MIN_AGE_MS): a freshly-orphaned record is held
|
|
345
|
+
// back until it is provably stale, so a transient probe miss never auto-prunes live work.
|
|
346
|
+
// Every other safety rule is inherited unchanged from the core — active/pending/generating
|
|
347
|
+
// work and fresh unacknowledged dispatch failures are never pruned, ledger-only audit entries
|
|
348
|
+
// are preserved, and the prune itself is recorded with a direct_dispatch_pruned ledger entry.
|
|
349
|
+
// Idempotent: a pruned row is gone from getActiveDirectDispatches, so the next tick finds
|
|
350
|
+
// nothing to re-prune. Isolated in its own try/catch per mesh so it can never kill the tick.
|
|
351
|
+
{
|
|
352
|
+
const minAgeMs = resolveAutoPruneMinAgeMs();
|
|
353
|
+
for (const mesh of listMeshes()) {
|
|
354
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
355
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
356
|
+
try {
|
|
357
|
+
await autoPruneStaleDirectDispatches(components, mesh, selfIds, localDaemonId, minAgeMs);
|
|
358
|
+
} catch (e: any) {
|
|
359
|
+
LOG.warn('MeshReconcile', `Auto-prune stale direct failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
313
364
|
// ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
|
|
314
365
|
const coordinators = findLiveCoordinators(components);
|
|
315
366
|
if (coordinators.length === 0) {
|
|
@@ -592,6 +643,98 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
592
643
|
}
|
|
593
644
|
}
|
|
594
645
|
|
|
646
|
+
// PHASE 5 helper. Build the live-node view (mesh.nodes decorated with each node's live
|
|
647
|
+
// session list) and run the shared prune core in execute mode with the conservative age gate.
|
|
648
|
+
//
|
|
649
|
+
// Orphan detection needs the SAME live-session evidence the manual MCP prune uses: a node still
|
|
650
|
+
// in mesh.nodes whose session list no longer contains the dispatched sessionId is "session not
|
|
651
|
+
// present" (prunable); a node missing from mesh.nodes entirely is "node no longer in live mesh"
|
|
652
|
+
// (prunable). We obtain live sessions per node via get_status_metadata — local nodes through the
|
|
653
|
+
// local commandHandler, remote nodes over P2P (dispatchMeshCommand) — exactly the transports
|
|
654
|
+
// PHASE 4 already uses. A node we cannot probe (offline) keeps an empty session list; combined
|
|
655
|
+
// with the age gate that only matters once the orphan is genuinely old.
|
|
656
|
+
//
|
|
657
|
+
// O(1) fast exit: when there are no active direct dispatches at all there is nothing to prune,
|
|
658
|
+
// so we skip the (per-node) status probes entirely — an idle mesh costs one indexed query.
|
|
659
|
+
async function autoPruneStaleDirectDispatches(
|
|
660
|
+
components: DaemonComponents,
|
|
661
|
+
mesh: LocalMeshEntry,
|
|
662
|
+
selfIds: string[],
|
|
663
|
+
localDaemonId: string | undefined,
|
|
664
|
+
minAgeMs: number,
|
|
665
|
+
): Promise<void> {
|
|
666
|
+
const directDispatches = getActiveDirectDispatches(mesh.id);
|
|
667
|
+
if (directDispatches.length === 0) return; // nothing dispatched → nothing to prune
|
|
668
|
+
|
|
669
|
+
const liveNodes = await collectLiveNodesWithSessions(components, mesh, selfIds, localDaemonId);
|
|
670
|
+
|
|
671
|
+
const result = pruneStaleDirectDispatches({
|
|
672
|
+
meshId: mesh.id,
|
|
673
|
+
queue: getQueue(mesh.id),
|
|
674
|
+
ledgerEntries: readLedgerEntries(mesh.id, { tail: 500 }),
|
|
675
|
+
directDispatches,
|
|
676
|
+
nodes: liveNodes,
|
|
677
|
+
execute: true,
|
|
678
|
+
minAgeMs,
|
|
679
|
+
source: 'daemon_reconcile_auto_prune',
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Log only when something was actually pruned — silence on the common no-op tick.
|
|
683
|
+
if (result.prunedCount > 0) {
|
|
684
|
+
LOG.info('MeshReconcile', `Auto-pruned ${result.prunedCount} orphaned direct dispatch record(s) for mesh ${mesh.id}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Probe each node for its live session list (get_status_metadata) and return mesh.nodes
|
|
689
|
+
// decorated with a `sessions` array — the shape buildMeshActiveWork / sessionStatusFromNodes
|
|
690
|
+
// consume to decide whether a dispatched session is still present. Best-effort: an unreachable
|
|
691
|
+
// node yields an empty session list rather than throwing.
|
|
692
|
+
async function collectLiveNodesWithSessions(
|
|
693
|
+
components: DaemonComponents,
|
|
694
|
+
mesh: LocalMeshEntry,
|
|
695
|
+
selfIds: string[],
|
|
696
|
+
localDaemonId: string | undefined,
|
|
697
|
+
): Promise<any[]> {
|
|
698
|
+
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
699
|
+
return Promise.all(mesh.nodes.map(async (node) => {
|
|
700
|
+
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
701
|
+
const isLocalNode = !nodeDaemonId
|
|
702
|
+
|| selfIds.includes(nodeDaemonId)
|
|
703
|
+
|| (localDaemonId !== undefined && nodeDaemonId === localDaemonId);
|
|
704
|
+
let statusResult: unknown;
|
|
705
|
+
try {
|
|
706
|
+
if (isLocalNode) {
|
|
707
|
+
statusResult = await components.commandHandler.handle('get_status_metadata', {});
|
|
708
|
+
} else if (dispatchMeshCommand) {
|
|
709
|
+
statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
|
|
710
|
+
} else {
|
|
711
|
+
return node; // remote node, no P2P transport — leave undecorated
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
return node; // unreachable — leave undecorated (empty session list)
|
|
715
|
+
}
|
|
716
|
+
const sessions = extractStatusMetadataSessions(statusResult);
|
|
717
|
+
return sessions.length > 0 ? { ...node, sessions } : node;
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Pull the live session list out of a get_status_metadata result, tolerating the same
|
|
722
|
+
// envelope shapes unwrapReadChatPayload handles (direct CommandResult or { payload }/{ result }).
|
|
723
|
+
function extractStatusMetadataSessions(raw: unknown): any[] {
|
|
724
|
+
let cursor: unknown = raw;
|
|
725
|
+
for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
|
|
726
|
+
const record = cursor as Record<string, unknown>;
|
|
727
|
+
const status = record.status && typeof record.status === 'object' ? record.status as Record<string, unknown> : undefined;
|
|
728
|
+
if (status && Array.isArray(status.sessions)) return status.sessions;
|
|
729
|
+
if (Array.isArray(record.sessions)) return record.sessions;
|
|
730
|
+
if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
|
|
731
|
+
if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
|
|
732
|
+
if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
|
|
595
738
|
function extractPendingEvents(raw: unknown): any[] {
|
|
596
739
|
if (Array.isArray(raw)) return raw;
|
|
597
740
|
if (raw && typeof raw === 'object') {
|
|
@@ -329,8 +329,8 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
329
329
|
this.settings = context.settings || {};
|
|
330
330
|
this.monitor.updateConfig({
|
|
331
331
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
332
|
-
|
|
333
|
-
|
|
332
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
333
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
334
334
|
});
|
|
335
335
|
|
|
336
336
|
await this.spawnAgent();
|
|
@@ -674,8 +674,8 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
674
674
|
this.settings = { ...this.settings, ...newSettings };
|
|
675
675
|
this.monitor.updateConfig({
|
|
676
676
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
677
|
-
|
|
678
|
-
|
|
677
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
678
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
679
679
|
});
|
|
680
680
|
this.log.info(`[${this.type}] Settings updated: ${Object.keys(newSettings).join(', ')}`);
|
|
681
681
|
}
|
|
@@ -1533,7 +1533,8 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
1533
1533
|
|
|
1534
1534
|
// Monitor check
|
|
1535
1535
|
const agentKey = `${this.type}:acp`;
|
|
1536
|
-
const
|
|
1536
|
+
const approvalPending = newStatus === 'waiting_approval';
|
|
1537
|
+
const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
|
|
1537
1538
|
for (const me of monitorEvents) {
|
|
1538
1539
|
this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
|
|
1539
1540
|
}
|
|
@@ -183,7 +183,7 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
function isCliGeneratingLikeStatus(status: unknown): boolean {
|
|
186
|
-
return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
|
|
186
|
+
return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export function buildCliStructuredInputPrompt(
|
|
@@ -479,8 +479,8 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
479
479
|
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
480
480
|
this.monitor.updateConfig({
|
|
481
481
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
482
|
-
|
|
483
|
-
|
|
482
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
483
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
484
484
|
});
|
|
485
485
|
|
|
486
486
|
// Server connection
|
|
@@ -698,7 +698,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
698
698
|
&& adapterStatus.status === 'idle'
|
|
699
699
|
&& parsedStatus?.status === 'idle';
|
|
700
700
|
let messagesToSave = parsedMessages;
|
|
701
|
-
if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
|
|
701
|
+
if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
|
|
702
702
|
const lastIdx = messagesToSave.length - 1;
|
|
703
703
|
if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
|
|
704
704
|
messagesToSave = messagesToSave.slice(0, lastIdx);
|
|
@@ -866,8 +866,8 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
866
866
|
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
867
867
|
this.monitor.updateConfig({
|
|
868
868
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
869
|
-
|
|
870
|
-
|
|
869
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
870
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
871
871
|
});
|
|
872
872
|
}
|
|
873
873
|
|
|
@@ -1545,7 +1545,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1545
1545
|
const dirName = workingDirBasename(this.workingDir);
|
|
1546
1546
|
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
1547
1547
|
const partial = this.adapter.getPartialResponse();
|
|
1548
|
-
// Liveness fingerprint for the
|
|
1548
|
+
// Liveness fingerprint for the no-progress watchdog. The parsed
|
|
1549
1549
|
// assistant buffer (`partial`) alone goes static while a tool/build runs
|
|
1550
1550
|
// — the assistant emits no tokens even though the PTY is actively
|
|
1551
1551
|
// printing tool output — which made the watchdog false-fire a "stuck"
|
|
@@ -1764,11 +1764,15 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1764
1764
|
|
|
1765
1765
|
// Monitor check (cooldown based notification, IDE/CLI common)
|
|
1766
1766
|
const agentKey = `${this.type}:cli`;
|
|
1767
|
-
|
|
1767
|
+
// Approval pending is detected from the raw adapter status, not `newStatus`:
|
|
1768
|
+
// auto-approve synthesizes `waiting_approval` → 'generating', which would
|
|
1769
|
+
// otherwise let the no-progress watchdog accumulate the approval wait.
|
|
1770
|
+
const approvalPending = rawStatus === 'waiting_approval';
|
|
1771
|
+
const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
|
|
1768
1772
|
const monitorParsedStatus: any = parsedStatus;
|
|
1769
1773
|
for (const me of monitorEvents) {
|
|
1770
1774
|
if (
|
|
1771
|
-
me.type === 'monitor:
|
|
1775
|
+
me.type === 'monitor:no_progress'
|
|
1772
1776
|
&& this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
|
|
1773
1777
|
&& !this.hasAdapterPendingResponse()
|
|
1774
1778
|
&& !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
|
|
@@ -1783,7 +1787,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1783
1787
|
providerType: this.type,
|
|
1784
1788
|
sessionId: this.instanceId,
|
|
1785
1789
|
providerSessionId: this.providerSessionId || null,
|
|
1786
|
-
reconciliationReason: '
|
|
1790
|
+
reconciliationReason: 'no_progress_monitor_final_summary',
|
|
1787
1791
|
finalAssistantPresent: true,
|
|
1788
1792
|
},
|
|
1789
1793
|
});
|
|
@@ -63,8 +63,8 @@ export class ExtensionProviderInstance implements ProviderInstance {
|
|
|
63
63
|
this.settings = context.settings || {};
|
|
64
64
|
this.monitor.updateConfig({
|
|
65
65
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
67
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -178,8 +178,8 @@ export class ExtensionProviderInstance implements ProviderInstance {
|
|
|
178
178
|
this.settings = { ...this.settings, ...newSettings };
|
|
179
179
|
this.monitor.updateConfig({
|
|
180
180
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
182
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -251,9 +251,10 @@ export class ExtensionProviderInstance implements ProviderInstance {
|
|
|
251
251
|
: 'immediate',
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
-
// Monitor check (cooldown based notification) — keep monitor events (
|
|
254
|
+
// Monitor check (cooldown based notification) — keep monitor events (no_progress etc)
|
|
255
255
|
const agentKey = `${this.type}:ext`;
|
|
256
|
-
const
|
|
256
|
+
const approvalPending = agentStatus === 'waiting_approval';
|
|
257
|
+
const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint, approvalPending);
|
|
257
258
|
for (const me of monitorEvents) {
|
|
258
259
|
this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
|
|
259
260
|
}
|
|
@@ -105,8 +105,8 @@ export class IdeProviderInstance implements ProviderInstance {
|
|
|
105
105
|
// Sync Monitor config
|
|
106
106
|
this.monitor.updateConfig({
|
|
107
107
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
109
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -267,8 +267,8 @@ export class IdeProviderInstance implements ProviderInstance {
|
|
|
267
267
|
this.settings = { ...this.settings, ...newSettings };
|
|
268
268
|
this.monitor.updateConfig({
|
|
269
269
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
271
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
|
|
@@ -417,7 +417,7 @@ export class IdeProviderInstance implements ProviderInstance {
|
|
|
417
417
|
const persistedMessages = chat.messages || messages;
|
|
418
418
|
if (persistedMessages.length > 0) {
|
|
419
419
|
let toSave = persistedMessages;
|
|
420
|
-
if (chat.status === 'generating' || chat.status === 'long_generating') {
|
|
420
|
+
if (chat.status === 'generating' || chat.status === 'no_progress' || chat.status === 'long_generating') {
|
|
421
421
|
// Find and exclude last assistant message
|
|
422
422
|
const lastIdx = toSave.length - 1;
|
|
423
423
|
if (lastIdx >= 0 && toSave[lastIdx].role === 'assistant') {
|
|
@@ -508,7 +508,11 @@ export class IdeProviderInstance implements ProviderInstance {
|
|
|
508
508
|
}
|
|
509
509
|
|
|
510
510
|
// Monitor check (cooldown based notification)
|
|
511
|
-
|
|
511
|
+
// Approval pending is detected from the raw status: auto-approve synthesizes
|
|
512
|
+
// `waiting_approval` → 'generating', so the no-progress watchdog must be told
|
|
513
|
+
// to hold its timer during the wait rather than count it as a stall.
|
|
514
|
+
const approvalPending = rawAgentStatus === 'waiting_approval';
|
|
515
|
+
const monitorEvents = this.monitor.check(agentKey, agentStatus, now, progressFingerprint, approvalPending);
|
|
512
516
|
for (const me of monitorEvents) {
|
|
513
517
|
this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
|
|
514
518
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ReadChatResultV2,
|
|
11
11
|
} from './transcript-v2.js'
|
|
12
12
|
|
|
13
|
-
const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'long_generating'] as const
|
|
13
|
+
const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'no_progress', 'long_generating'] as const
|
|
14
14
|
const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
|
|
15
15
|
const VALID_BUBBLE_STATES = ['draft', 'streaming', 'final', 'removed'] as const
|
|
16
16
|
const VALID_TURN_STATUSES = ['open', 'waiting_approval', 'complete', 'error'] as const
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common across all Provider categories (IDE/Extension/CLI/ACP).
|
|
5
5
|
* - Approval waiting (waiting_approval) notification
|
|
6
|
-
* -
|
|
6
|
+
* - No-progress watchdog: alert when an active turn makes no progress for a while
|
|
7
7
|
* - All config toggleable via Provider Settings
|
|
8
8
|
*/
|
|
9
9
|
export interface MonitorConfig {
|
|
10
10
|
/** Enable awaiting-approval notification */
|
|
11
11
|
approvalAlert: boolean;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
12
|
+
/** No-progress watchdog notification enabled */
|
|
13
|
+
noProgressAlert: boolean;
|
|
14
|
+
/** No-progress threshold (seconds) */
|
|
15
|
+
noProgressThresholdSec: number;
|
|
16
16
|
/** Repeat notification cooldown (seconds) */
|
|
17
17
|
alertCooldownSec: number;
|
|
18
18
|
}
|
|
@@ -28,7 +28,7 @@ export declare class StatusMonitor {
|
|
|
28
28
|
private config;
|
|
29
29
|
private lastAlertTime;
|
|
30
30
|
private generatingStartTimes;
|
|
31
|
-
private
|
|
31
|
+
private noProgressAlerted;
|
|
32
32
|
private lastProgressFingerprint;
|
|
33
33
|
private lastProgressChangeAt;
|
|
34
34
|
constructor(config?: Partial<MonitorConfig>);
|
|
@@ -40,7 +40,7 @@ export declare class StatusMonitor {
|
|
|
40
40
|
* Check status transition → return notification event array.
|
|
41
41
|
* Called from each onTick() or detectStatusTransition().
|
|
42
42
|
*/
|
|
43
|
-
check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[];
|
|
43
|
+
check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[];
|
|
44
44
|
/** Cooldown check — prevent sending the same notification too frequently */
|
|
45
45
|
private shouldAlert;
|
|
46
46
|
/** Reset (on agent terminate/restart) */
|
|
@@ -3,26 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common across all Provider categories (IDE/Extension/CLI/ACP).
|
|
5
5
|
* - Approval waiting (waiting_approval) notification
|
|
6
|
-
* -
|
|
6
|
+
* - No-progress watchdog: alert when an active turn makes no progress for a while
|
|
7
7
|
* - All config toggleable via Provider Settings
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export interface MonitorConfig {
|
|
11
11
|
/** Enable awaiting-approval notification */
|
|
12
12
|
approvalAlert: boolean;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
13
|
+
/** No-progress watchdog notification enabled */
|
|
14
|
+
noProgressAlert: boolean;
|
|
15
|
+
/** No-progress threshold (seconds) */
|
|
16
|
+
noProgressThresholdSec: number;
|
|
17
17
|
/** Repeat notification cooldown (seconds) */
|
|
18
18
|
alertCooldownSec: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const DEFAULT_MONITOR_CONFIG: MonitorConfig = {
|
|
22
22
|
approvalAlert: true,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
alertCooldownSec: 60,
|
|
23
|
+
noProgressAlert: true,
|
|
24
|
+
noProgressThresholdSec: 180, // 3 minutes
|
|
25
|
+
alertCooldownSec: 60, // 1 minute cooldown
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export interface MonitorEvent {
|
|
@@ -37,7 +37,7 @@ export class StatusMonitor {
|
|
|
37
37
|
private config: MonitorConfig;
|
|
38
38
|
private lastAlertTime = new Map<string, number>();
|
|
39
39
|
private generatingStartTimes = new Map<string, number>();
|
|
40
|
-
private
|
|
40
|
+
private noProgressAlerted = new Map<string, boolean>();
|
|
41
41
|
private lastProgressFingerprint = new Map<string, string>();
|
|
42
42
|
private lastProgressChangeAt = new Map<string, number>();
|
|
43
43
|
|
|
@@ -59,7 +59,7 @@ export class StatusMonitor {
|
|
|
59
59
|
* Check status transition → return notification event array.
|
|
60
60
|
* Called from each onTick() or detectStatusTransition().
|
|
61
61
|
*/
|
|
62
|
-
check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[] {
|
|
62
|
+
check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[] {
|
|
63
63
|
const events: MonitorEvent[] = [];
|
|
64
64
|
|
|
65
65
|
// 1. Approval waiting notification
|
|
@@ -74,11 +74,26 @@ export class StatusMonitor {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// 2.
|
|
77
|
+
// 2. No-progress watchdog (identical for IDE/Extension/CLI/ACP)
|
|
78
78
|
if (status === 'generating' || status === 'streaming') {
|
|
79
|
+
// While an approval modal is pending we must NOT count the wait as
|
|
80
|
+
// no-progress. The assistant emits no tokens and the screen is frozen
|
|
81
|
+
// on the modal, so the progress fingerprint goes static — but this is
|
|
82
|
+
// a user action wait, not a freeze. Some callers (CLI/IDE auto-approve)
|
|
83
|
+
// also synthesize the reported status to 'generating' during the wait,
|
|
84
|
+
// so the plain `waiting_approval` branch below cannot catch it. Hold the
|
|
85
|
+
// progress anchor at `now` so the elapsed timer restarts the moment the
|
|
86
|
+
// approval clears and real generating resumes.
|
|
87
|
+
if (approvalPending) {
|
|
88
|
+
this.generatingStartTimes.set(agentKey, now);
|
|
89
|
+
this.lastProgressFingerprint.set(agentKey, progressFingerprint ?? '');
|
|
90
|
+
this.lastProgressChangeAt.set(agentKey, now);
|
|
91
|
+
this.noProgressAlerted.set(agentKey, false);
|
|
92
|
+
return events;
|
|
93
|
+
}
|
|
79
94
|
if (!this.generatingStartTimes.has(agentKey)) {
|
|
80
95
|
this.generatingStartTimes.set(agentKey, now);
|
|
81
|
-
this.
|
|
96
|
+
this.noProgressAlerted.set(agentKey, false);
|
|
82
97
|
const initialFingerprint = progressFingerprint ?? '';
|
|
83
98
|
this.lastProgressFingerprint.set(agentKey, initialFingerprint);
|
|
84
99
|
this.lastProgressChangeAt.set(agentKey, now);
|
|
@@ -88,17 +103,17 @@ export class StatusMonitor {
|
|
|
88
103
|
if (previousFingerprint !== currentFingerprint) {
|
|
89
104
|
this.lastProgressFingerprint.set(agentKey, currentFingerprint);
|
|
90
105
|
this.lastProgressChangeAt.set(agentKey, now);
|
|
91
|
-
this.
|
|
106
|
+
this.noProgressAlerted.set(agentKey, false);
|
|
92
107
|
}
|
|
93
|
-
if (this.config.
|
|
108
|
+
if (this.config.noProgressAlert) {
|
|
94
109
|
const progressChangedAt = this.lastProgressChangeAt.get(agentKey) || this.generatingStartTimes.get(agentKey)!;
|
|
95
110
|
const elapsedSec = Math.round((now - progressChangedAt) / 1000);
|
|
96
|
-
const alreadyAlerted = this.
|
|
97
|
-
if (elapsedSec > this.config.
|
|
98
|
-
if (this.shouldAlert(agentKey + ':
|
|
99
|
-
this.
|
|
111
|
+
const alreadyAlerted = this.noProgressAlerted.get(agentKey) === true;
|
|
112
|
+
if (elapsedSec > this.config.noProgressThresholdSec && !alreadyAlerted) {
|
|
113
|
+
if (this.shouldAlert(agentKey + ':no_progress', now)) {
|
|
114
|
+
this.noProgressAlerted.set(agentKey, true);
|
|
100
115
|
events.push({
|
|
101
|
-
type: 'monitor:
|
|
116
|
+
type: 'monitor:no_progress',
|
|
102
117
|
agentKey,
|
|
103
118
|
elapsedSec,
|
|
104
119
|
timestamp: now,
|
|
@@ -110,7 +125,7 @@ export class StatusMonitor {
|
|
|
110
125
|
} else {
|
|
111
126
|
// Reset timer when switching to non-generating status
|
|
112
127
|
this.generatingStartTimes.delete(agentKey);
|
|
113
|
-
this.
|
|
128
|
+
this.noProgressAlerted.delete(agentKey);
|
|
114
129
|
this.lastProgressFingerprint.delete(agentKey);
|
|
115
130
|
this.lastProgressChangeAt.delete(agentKey);
|
|
116
131
|
}
|
|
@@ -132,7 +147,7 @@ export class StatusMonitor {
|
|
|
132
147
|
reset(agentKey?: string): void {
|
|
133
148
|
if (agentKey) {
|
|
134
149
|
this.generatingStartTimes.delete(agentKey);
|
|
135
|
-
this.
|
|
150
|
+
this.noProgressAlerted.delete(agentKey);
|
|
136
151
|
this.lastProgressFingerprint.delete(agentKey);
|
|
137
152
|
this.lastProgressChangeAt.delete(agentKey);
|
|
138
153
|
// Delete all cooldowns for this key
|
|
@@ -141,7 +156,7 @@ export class StatusMonitor {
|
|
|
141
156
|
}
|
|
142
157
|
} else {
|
|
143
158
|
this.generatingStartTimes.clear();
|
|
144
|
-
this.
|
|
159
|
+
this.noProgressAlerted.clear();
|
|
145
160
|
this.lastProgressFingerprint.clear();
|
|
146
161
|
this.lastProgressChangeAt.clear();
|
|
147
162
|
this.lastAlertTime.clear();
|
package/src/shared-types.ts
CHANGED
|
@@ -740,6 +740,9 @@ export type DaemonStatusEventName =
|
|
|
740
740
|
| 'agent:waiting_approval'
|
|
741
741
|
| 'agent:generating_completed'
|
|
742
742
|
| 'agent:stopped'
|
|
743
|
+
| 'monitor:no_progress'
|
|
744
|
+
// Legacy alias for 'monitor:no_progress' — kept so older daemons that still
|
|
745
|
+
// emit it remain type-compatible with consumers during rollout.
|
|
743
746
|
| 'monitor:long_generating';
|
|
744
747
|
|
|
745
748
|
/** Minimal daemon-originated event payload relayed through the server. */
|
package/src/status/reporter.ts
CHANGED