@adhdev/daemon-core 0.9.82-rc.455 → 0.9.82-rc.457
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/commands/med-family/mesh-crud.d.ts +19 -0
- package/dist/config/config.d.ts +14 -0
- package/dist/config/registry-resolver.d.ts +54 -0
- package/dist/index.js +369 -67
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -67
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/preview-freshness.d.ts +11 -1
- package/dist/mesh/worktree-bootstrap-config.d.ts +9 -0
- package/dist/providers/approval-utils.d.ts +25 -0
- package/dist/providers/cli-provider-instance.d.ts +30 -1
- package/dist/providers/manual-attendance.d.ts +16 -0
- package/dist/providers/provider-instance.d.ts +8 -1
- package/dist/providers/provider-loader.d.ts +17 -2
- package/dist/providers/spec/fsm-driver.d.ts +5 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +2 -0
- package/src/commands/handler.ts +9 -5
- package/src/commands/low-family/daemon-lifecycle.ts +14 -1
- package/src/commands/med-family/mesh-crud.ts +83 -49
- package/src/config/config.ts +18 -0
- package/src/config/registry-resolver.ts +100 -0
- package/src/mesh/preview-freshness.ts +46 -1
- package/src/mesh/worktree-bootstrap-config.ts +1 -1
- package/src/providers/approval-utils.ts +42 -0
- package/src/providers/cli-provider-instance.ts +246 -13
- package/src/providers/manual-attendance.ts +20 -0
- package/src/providers/provider-instance.ts +6 -1
- package/src/providers/provider-loader.ts +36 -9
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +13 -2
- package/src/providers/spec/fsm-driver.ts +49 -2
- package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +0 -198
|
@@ -23,6 +23,46 @@ export interface PreviewFreshness {
|
|
|
23
23
|
|
|
24
24
|
const PREVIEW_DEPLOY_RECORD = '.adhdev/preview-deploy.json';
|
|
25
25
|
|
|
26
|
+
// Repo-relative driver scripts that indicate this repository actually ships the
|
|
27
|
+
// preview-deploy pipeline. Presence of any one of these (or the deploy record,
|
|
28
|
+
// or a `deploy:preview` npm script) means the private release-pipeline guidance
|
|
29
|
+
// carried by buildPreviewFreshness is relevant here. In every other repo the
|
|
30
|
+
// pipeline is not configured and the guidance must NOT leak (F15).
|
|
31
|
+
const PREVIEW_PIPELINE_SCRIPTS = [
|
|
32
|
+
'scripts/preview-freshness.mjs',
|
|
33
|
+
'scripts/smoke-preview-web.mjs',
|
|
34
|
+
'scripts/deploy-preview-local.mjs',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
function hasDeployPreviewNpmScript(repoRoot: string): boolean {
|
|
38
|
+
const pkgPath = resolve(repoRoot, 'package.json');
|
|
39
|
+
if (!existsSync(pkgPath)) return false;
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, unknown> };
|
|
42
|
+
return typeof pkg?.scripts?.['deploy:preview'] === 'string';
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gate: does this repository actually configure the preview-deploy pipeline?
|
|
50
|
+
*
|
|
51
|
+
* The preview-freshness surface embeds this project's private release-pipeline
|
|
52
|
+
* instructions (`npm run deploy:preview`, smoke preview, …). Those are only
|
|
53
|
+
* meaningful in a repo that ships the pipeline. An external repo joined to a
|
|
54
|
+
* mesh must not have that guidance leak into its coordinator prompt, so this
|
|
55
|
+
* gate keeps the surface off unless a concrete pipeline artifact is present.
|
|
56
|
+
*/
|
|
57
|
+
export function isPreviewPipelineConfigured(repoRoot: string): boolean {
|
|
58
|
+
// Strongest signal: the repo has produced a preview-deploy record before.
|
|
59
|
+
if (existsSync(resolve(repoRoot, PREVIEW_DEPLOY_RECORD))) return true;
|
|
60
|
+
// Otherwise the pipeline's own driver scripts are enough.
|
|
61
|
+
if (PREVIEW_PIPELINE_SCRIPTS.some((rel) => existsSync(resolve(repoRoot, rel)))) return true;
|
|
62
|
+
// Or the `deploy:preview` npm script that fronts the pipeline.
|
|
63
|
+
return hasDeployPreviewNpmScript(repoRoot);
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
function runGit(repoRoot: string, args: readonly string[]): string {
|
|
27
67
|
try {
|
|
28
68
|
return execFileSync('git', args, {
|
|
@@ -86,7 +126,12 @@ function readCurrentMainCommit(repoRoot: string): Pick<PreviewFreshness, 'curren
|
|
|
86
126
|
return { currentMainCommit: null, currentMainCommitSource: 'unknown' };
|
|
87
127
|
}
|
|
88
128
|
|
|
89
|
-
export function buildPreviewFreshness(repoRoot: string): PreviewFreshness {
|
|
129
|
+
export function buildPreviewFreshness(repoRoot: string): PreviewFreshness | null {
|
|
130
|
+
// F15 gate: only surface preview-freshness (and its private pipeline
|
|
131
|
+
// guidance) in repos that actually configure the preview-deploy pipeline.
|
|
132
|
+
// Unconfigured repos return null so the caller omits the field entirely.
|
|
133
|
+
if (!isPreviewPipelineConfigured(repoRoot)) return null;
|
|
134
|
+
|
|
90
135
|
const current = readCurrentMainCommit(repoRoot);
|
|
91
136
|
const record = readRecord(repoRoot);
|
|
92
137
|
const lastPreviewCommit = normalizeCommit(record?.lastPreviewCommit);
|
|
@@ -69,7 +69,7 @@ export const WORKTREE_BOOTSTRAP_STALE_RUNNING_MS = 10 * 60 * 1000;
|
|
|
69
69
|
* trailing slash stripped. Returns an empty set when there are no submodules or the
|
|
70
70
|
* lookup fails — callers must then treat any change as dirty (conservative).
|
|
71
71
|
*/
|
|
72
|
-
function getRegisteredSubmodulePaths(workspace: string): Set<string> {
|
|
72
|
+
export function getRegisteredSubmodulePaths(workspace: string): Set<string> {
|
|
73
73
|
const paths = new Set<string>();
|
|
74
74
|
try {
|
|
75
75
|
const out = execFileSync(
|
|
@@ -41,6 +41,48 @@ export function hasNegativeApprovalOption(buttons: string[] | null | undefined):
|
|
|
41
41
|
return (buttons || []).some((button) => isNegativeApprovalLabel(String(button || '')));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* True when a button reliably identifies a tool-CONSENT modal on its own — a
|
|
46
|
+
* scoped permission-grant affirmative such as:
|
|
47
|
+
* - "Yes, allow all edits in tmp/ during this session"
|
|
48
|
+
* - "Yes, and don't ask again for example.com"
|
|
49
|
+
* - "Yes, allow reading from etc/ from this project"
|
|
50
|
+
* - "Always allow"
|
|
51
|
+
*
|
|
52
|
+
* These options only ever appear in a genuine approval/permission prompt; a
|
|
53
|
+
* /model or /mode picker ("1. Default 2. Opus 3. Sonnet") never offers a
|
|
54
|
+
* "grant this scope" choice. They therefore serve as a SECOND reliable
|
|
55
|
+
* structural anchor alongside {@link hasNegativeApprovalOption}.
|
|
56
|
+
*
|
|
57
|
+
* Why this exists (tall-diff fallback, #137): when a Write/Edit diff is tall,
|
|
58
|
+
* the trailing decline option ("3. No") can scroll off the bottom of the
|
|
59
|
+
* captured PTY frame, leaving only "1. Yes" + "2. Yes, allow … this session".
|
|
60
|
+
* hasNegativeApprovalOption then reads false and the auto-approve gate bails —
|
|
61
|
+
* a delegated worker sits forever on a modal it could safely have approved. The
|
|
62
|
+
* grant-scope affirmative lets the gate recognize the consent modal WITHOUT
|
|
63
|
+
* seeing the off-frame decline. The gate still selects the plain "Yes"
|
|
64
|
+
* (allow-once) via pickApprovalButton, never the broader grant, and the settle
|
|
65
|
+
* gate still requires a stable modal — so a half-rendered frame never fires.
|
|
66
|
+
* Kept deliberately narrow so no picker/confirm modal can trip it.
|
|
67
|
+
*/
|
|
68
|
+
export function hasReliableApprovalAffirmative(buttons: string[] | null | undefined): boolean {
|
|
69
|
+
return (buttons || []).some((button) => {
|
|
70
|
+
const label = normalizeApprovalLabel(String(button || ''));
|
|
71
|
+
if (!label) return false;
|
|
72
|
+
// "always allow …" as a standalone grant option.
|
|
73
|
+
if (/^always allow\b/.test(label)) return true;
|
|
74
|
+
// "yes, …" scoped grants: allow / always allow / don't ask again / etc.
|
|
75
|
+
// (normalizeApprovalLabel strips the apostrophe, so "don't" → "don t").
|
|
76
|
+
if (/^yes\b/.test(label)) {
|
|
77
|
+
if (/\ballow\b/.test(label)) return true;
|
|
78
|
+
if (/\bask again\b/.test(label)) return true;
|
|
79
|
+
if (/\bduring this session\b/.test(label)) return true;
|
|
80
|
+
if (/\bfrom this project\b/.test(label)) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
44
86
|
export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
|
|
45
87
|
const customHints = Array.isArray(provider?.approvalPositiveHints)
|
|
46
88
|
? provider.approvalPositiveHints
|
|
@@ -22,10 +22,12 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
|
|
|
22
22
|
import { StatusMonitor } from './status-monitor.js';
|
|
23
23
|
import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
|
|
24
24
|
import { LOG } from '../logging/logger.js';
|
|
25
|
+
import { recordDebugTrace } from '../logging/debug-trace.js';
|
|
26
|
+
import { shouldCollectTraceCategory } from '../logging/debug-config.js';
|
|
25
27
|
import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
|
|
26
28
|
import type { ChatMessage } from '../types.js';
|
|
27
29
|
import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
|
|
28
|
-
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
30
|
+
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
29
31
|
import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
|
|
30
32
|
import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
|
|
31
33
|
import { normalizeProviderSessionId } from './provider-session-id.js';
|
|
@@ -572,6 +574,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
572
574
|
// mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
|
|
573
575
|
// (modal genuinely gone, manual attendance takes over, or auto-approve fires).
|
|
574
576
|
private autoApproveMaskSince = 0;
|
|
577
|
+
// NOTIF-APPROVAL-MASKED (Q1b): the autoApproveMaskSince episode value for which a
|
|
578
|
+
// stalled-approval coordinator nudge has already been emitted, so the nudge fires
|
|
579
|
+
// exactly once per stalled auto-approve episode (0 = none emitted). Reusing the
|
|
580
|
+
// per-episode mask-clock value as the key makes it provider-agnostic (no reliance on
|
|
581
|
+
// approvalEntrySeq) and self-resetting: each new episode gets a fresh
|
|
582
|
+
// autoApproveMaskSince timestamp, and the episode-end reset zeroes it.
|
|
583
|
+
private stalledApprovalNudgeEpisode = 0;
|
|
575
584
|
// Provider-common manual-attendance signal: while a human is actively driving
|
|
576
585
|
// this session from the dashboard, auto-approve holds so they can take manual
|
|
577
586
|
// control. Background mesh workers are never attended → delegated auto-approve
|
|
@@ -1519,9 +1528,23 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1519
1528
|
}
|
|
1520
1529
|
|
|
1521
1530
|
private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
|
|
1531
|
+
// (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
|
|
1532
|
+
// pure message-content check ("does the last visible bubble read as a finalized assistant
|
|
1533
|
+
// reply, post-dating the turn start?"). That LOWER bound alone treated the FIRST assistant
|
|
1534
|
+
// bubble of a turn that is STILL running — a tool call in flight between two assistant
|
|
1535
|
+
// bubbles — as proof the turn ended (RCA cases a & b). Require in ADDITION that the turn is
|
|
1536
|
+
// genuinely OVER: hasAdapterPendingResponse() folds the three upper-bound discriminators
|
|
1537
|
+
// into one — currentTurnScope closed, no in-flight tool (isProcessing false), and no partial
|
|
1538
|
+
// response buffer. So a mid-turn point-sample (short-gen / fast-collapse inline paths that
|
|
1539
|
+
// do NOT route through getCompletedFinalizationBlock) yields present=false and is held/settled
|
|
1540
|
+
// rather than fired. A genuinely-finished turn (adapter idle, no pending) is unaffected — the
|
|
1541
|
+
// gate stays open and the completion fires exactly as before. This mirrors the established
|
|
1542
|
+
// `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
|
|
1543
|
+
// used by the no-progress monitor reconcile path.
|
|
1544
|
+
const turnClosed = !this.hasAdapterPendingResponse();
|
|
1522
1545
|
if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
|
|
1523
1546
|
return {
|
|
1524
|
-
present:
|
|
1547
|
+
present: turnClosed,
|
|
1525
1548
|
messages: Array.isArray(parsedMessages) ? parsedMessages : [],
|
|
1526
1549
|
source: 'parsed',
|
|
1527
1550
|
};
|
|
@@ -1530,7 +1553,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1530
1553
|
const externalMessages = this.readExternalCompletionMessages();
|
|
1531
1554
|
if (externalMessages) {
|
|
1532
1555
|
return {
|
|
1533
|
-
present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
|
|
1556
|
+
present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
|
|
1534
1557
|
messages: externalMessages,
|
|
1535
1558
|
source: 'external-native',
|
|
1536
1559
|
};
|
|
@@ -1683,11 +1706,21 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1683
1706
|
|
|
1684
1707
|
const adapterAny = this.adapter as any;
|
|
1685
1708
|
const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1709
|
+
// (FALSEIDLE-a FixA) The adapter pending-response checks run UNCONDITIONALLY.
|
|
1710
|
+
// Previously they were SKIPPED when approvalResolvedIdle, on the assumption that
|
|
1711
|
+
// a waiting_approval→idle transition proved the approval's turn was over. But
|
|
1712
|
+
// auto-approve RESOLVES the modal and the agent RESUMES the same turn — currentTurnScope
|
|
1713
|
+
// / isWaitingForResponse stay set, or a tool runs — so skipping the guard let the FIRST
|
|
1714
|
+
// assistant bubble of the still-running turn be mistaken for the last and fired an early
|
|
1715
|
+
// completion the coordinator could never correct (RCA case a). Keep the guard live for the
|
|
1716
|
+
// approval path too: when the resumed turn genuinely ends these clear and the completion
|
|
1717
|
+
// fires. Approval-resolved holds are NON-terminal (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS)
|
|
1718
|
+
// so a provider that never closes its turn-scope still force-fires a weak completion rather
|
|
1719
|
+
// than wedging; the non-approval path keeps its terminal hold (a genuinely-busy adapter must
|
|
1720
|
+
// never force a completion out).
|
|
1721
|
+
if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: !approvalResolvedIdle };
|
|
1722
|
+
if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
|
|
1723
|
+
if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
|
|
1691
1724
|
|
|
1692
1725
|
const partial = typeof this.adapter.getPartialResponse === 'function'
|
|
1693
1726
|
? this.adapter.getPartialResponse()
|
|
@@ -1884,6 +1917,40 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1884
1917
|
};
|
|
1885
1918
|
}
|
|
1886
1919
|
|
|
1920
|
+
// COMPLETION-EARLYNOTIFY instrumentation. A session-keyed FSM-transition +
|
|
1921
|
+
// completion-gate snapshot recorded into the shared debug-trace ring buffer
|
|
1922
|
+
// (secret-safe, length/role/pattern-name only — never screen or bubble text).
|
|
1923
|
+
// Retrieved via getRecentDebugTrace (chat_debug_bundle). Both categories are a
|
|
1924
|
+
// no-op unless collectDebugTrace is on AND the category is selected, so the
|
|
1925
|
+
// hot-path guards below (completionTraceOn / fsmTraceOn) keep production cost
|
|
1926
|
+
// at a single boolean check.
|
|
1927
|
+
private completionTraceOn(): boolean {
|
|
1928
|
+
return shouldCollectTraceCategory('completion-gate');
|
|
1929
|
+
}
|
|
1930
|
+
private fsmTraceOn(): boolean {
|
|
1931
|
+
return shouldCollectTraceCategory('fsm-transition');
|
|
1932
|
+
}
|
|
1933
|
+
private recordCompletionGateTrace(stage: string, payload: Record<string, unknown>): void {
|
|
1934
|
+
recordDebugTrace({
|
|
1935
|
+
category: 'completion-gate',
|
|
1936
|
+
stage,
|
|
1937
|
+
level: 'debug',
|
|
1938
|
+
sessionId: this.instanceId,
|
|
1939
|
+
providerType: this.type,
|
|
1940
|
+
payload,
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
private recordFsmTransitionTrace(payload: Record<string, unknown>): void {
|
|
1944
|
+
recordDebugTrace({
|
|
1945
|
+
category: 'fsm-transition',
|
|
1946
|
+
stage: 'transition',
|
|
1947
|
+
level: 'debug',
|
|
1948
|
+
sessionId: this.instanceId,
|
|
1949
|
+
providerType: this.type,
|
|
1950
|
+
payload,
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1887
1954
|
private flushCompletedDebounceIfFinalized(): void {
|
|
1888
1955
|
const pending = this.completedDebouncePending;
|
|
1889
1956
|
if (!pending) {
|
|
@@ -1897,6 +1964,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1897
1964
|
LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
|
|
1898
1965
|
if (latestVisibleStatus !== 'idle') {
|
|
1899
1966
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
1967
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
1968
|
+
blockReason: 'resumed_status',
|
|
1969
|
+
latestVisibleStatus,
|
|
1970
|
+
previousStatus: pending.previousStatus,
|
|
1971
|
+
busyEpochAtArm: pending.busyEpochAtArm,
|
|
1972
|
+
busyEpoch: this.busyEpoch,
|
|
1973
|
+
});
|
|
1900
1974
|
this.completedDebouncePending = null;
|
|
1901
1975
|
this.completedDebounceTimer = null;
|
|
1902
1976
|
return;
|
|
@@ -1914,6 +1988,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1914
1988
|
// so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
|
|
1915
1989
|
if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
|
|
1916
1990
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
|
|
1991
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
1992
|
+
blockReason: 'busy_reentry',
|
|
1993
|
+
latestVisibleStatus,
|
|
1994
|
+
previousStatus: pending.previousStatus,
|
|
1995
|
+
busyEpochAtArm: pending.busyEpochAtArm,
|
|
1996
|
+
busyEpoch: this.busyEpoch,
|
|
1997
|
+
busyEpochDelta: this.busyEpoch - pending.busyEpochAtArm,
|
|
1998
|
+
});
|
|
1917
1999
|
this.completedDebouncePending = null;
|
|
1918
2000
|
this.completedDebounceTimer = null;
|
|
1919
2001
|
return;
|
|
@@ -1923,6 +2005,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1923
2005
|
&& typeof latestOutputAt === 'number'
|
|
1924
2006
|
&& latestOutputAt > pending.lastOutputAtArm) {
|
|
1925
2007
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
|
|
2008
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
2009
|
+
blockReason: 'new_pty_output',
|
|
2010
|
+
latestVisibleStatus,
|
|
2011
|
+
previousStatus: pending.previousStatus,
|
|
2012
|
+
lastOutputAtArm: pending.lastOutputAtArm,
|
|
2013
|
+
lastOutputAt: latestOutputAt,
|
|
2014
|
+
lastOutputAtDelta: latestOutputAt - pending.lastOutputAtArm,
|
|
2015
|
+
});
|
|
1926
2016
|
this.completedDebouncePending = null;
|
|
1927
2017
|
this.completedDebounceTimer = null;
|
|
1928
2018
|
return;
|
|
@@ -1968,6 +2058,17 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1968
2058
|
if (this.isMeshWorkerSession()) {
|
|
1969
2059
|
traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
|
|
1970
2060
|
}
|
|
2061
|
+
// COMPLETION-EARLYNOTIFY: a hold is the CORRECT outcome when the turn is not
|
|
2062
|
+
// yet proven done (the FixA/FixB gates route here); trace it so an early-notify
|
|
2063
|
+
// investigation can see the gate holding rather than firing.
|
|
2064
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
|
|
2065
|
+
blockReason,
|
|
2066
|
+
latestVisibleStatus,
|
|
2067
|
+
terminal: block.terminal === true,
|
|
2068
|
+
holdForTranscript: block.holdForTranscript === true,
|
|
2069
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2070
|
+
waitedMs,
|
|
2071
|
+
});
|
|
1971
2072
|
pending.loggedBlockReason = blockReason;
|
|
1972
2073
|
}
|
|
1973
2074
|
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
@@ -1990,6 +2091,19 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1990
2091
|
if (this.isMeshWorkerSession()) {
|
|
1991
2092
|
traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
|
|
1992
2093
|
}
|
|
2094
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
|
|
2095
|
+
path: isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'canon_c_decoupled' : 'forced_timeout',
|
|
2096
|
+
blockReason,
|
|
2097
|
+
latestVisibleStatus,
|
|
2098
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2099
|
+
finalAssistantPresent: (completionDiagnostic as any).finalAssistantPresent === true,
|
|
2100
|
+
evidenceSource: (completionDiagnostic as any).finalAssistantEvidenceSource ?? null,
|
|
2101
|
+
lastVisibleRole: (completionDiagnostic as any).lastVisibleRole ?? null,
|
|
2102
|
+
lastVisibleContentLen: (completionDiagnostic as any).lastVisibleContentLength ?? null,
|
|
2103
|
+
emittedAfterFinalizationTimeout,
|
|
2104
|
+
waitedMs,
|
|
2105
|
+
busyEpoch: this.busyEpoch,
|
|
2106
|
+
});
|
|
1993
2107
|
this.pushEvent({
|
|
1994
2108
|
event: 'agent:generating_completed',
|
|
1995
2109
|
chatTitle: pending.chatTitle,
|
|
@@ -2021,6 +2135,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2021
2135
|
if (this.isMeshWorkerSession()) {
|
|
2022
2136
|
traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
|
|
2023
2137
|
}
|
|
2138
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
|
|
2139
|
+
path: 'clean',
|
|
2140
|
+
latestVisibleStatus,
|
|
2141
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2142
|
+
finalAssistantPresent: true,
|
|
2143
|
+
duration: pending.duration,
|
|
2144
|
+
busyEpoch: this.busyEpoch,
|
|
2145
|
+
});
|
|
2024
2146
|
this.pushEvent({
|
|
2025
2147
|
event: 'agent:generating_completed',
|
|
2026
2148
|
chatTitle: pending.chatTitle,
|
|
@@ -2056,6 +2178,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2056
2178
|
// Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
|
|
2057
2179
|
// returns false), so end the mask episode.
|
|
2058
2180
|
this.autoApproveMaskSince = 0;
|
|
2181
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2059
2182
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2060
2183
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2061
2184
|
this.autoApproveSettleTimer = null;
|
|
@@ -2100,6 +2223,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2100
2223
|
// Modal has genuinely been gone past the hysteresis window → the episode ended;
|
|
2101
2224
|
// end the mask episode too (a later approval starts a fresh stall clock).
|
|
2102
2225
|
this.autoApproveMaskSince = 0;
|
|
2226
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2103
2227
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
2104
2228
|
return autoApproveActive;
|
|
2105
2229
|
}
|
|
@@ -2110,6 +2234,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2110
2234
|
// when zero so it survives modal-signature changes and hysteresis blips — it measures
|
|
2111
2235
|
// the true age of the unresolved auto-approve, not the per-signature settle window.
|
|
2112
2236
|
if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
|
|
2237
|
+
// NOTIF-APPROVAL-MASKED (Q1b): once this episode has stalled past the mask threshold,
|
|
2238
|
+
// surface the raw waiting_approval to the mesh coordinator (decoupled from the dashboard
|
|
2239
|
+
// mask). Placed on the active-approval path here — the single choke point that owns the
|
|
2240
|
+
// mask-stall clock and is re-driven throughout a silent stall (getState heartbeat,
|
|
2241
|
+
// detectStatusTransition, recheckAutoApproveSettled). No-op until the stall threshold trips.
|
|
2242
|
+
this.maybeEmitStalledApprovalNudge(adapterStatus, now);
|
|
2113
2243
|
const modal = adapterStatus.activeModal;
|
|
2114
2244
|
// (fix) Do not auto-approve when no concrete modal/buttons are present.
|
|
2115
2245
|
// Claude TUI flaps between paints; without this guard adapterStatus
|
|
@@ -2147,10 +2277,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2147
2277
|
return autoApproveActive;
|
|
2148
2278
|
}
|
|
2149
2279
|
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2280
|
+
// Structural decline anchor. A real approval offers BOTH an affirmative
|
|
2281
|
+
// and a decline — but on a TALL Write/Edit diff the trailing "3. No"
|
|
2282
|
+
// scrolls off the captured frame, leaving only "1. Yes" + "2. Yes, allow
|
|
2283
|
+
// … this session" so hasNegativeApprovalOption reads false (#137). A
|
|
2284
|
+
// scoped grant-affirmative ("Yes, allow … during this session" / "…don't
|
|
2285
|
+
// ask again") ONLY appears in a genuine consent modal (never a picker),
|
|
2286
|
+
// so it stands in for the off-frame decline as a reliable second anchor.
|
|
2287
|
+
// Conservative by construction: a picker without a grant-scope option
|
|
2288
|
+
// still bails here, and the fire below still picks the plain allow-once
|
|
2289
|
+
// "Yes" via pickApprovalButton, not the broader grant.
|
|
2290
|
+
const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
|
|
2291
|
+
|| hasReliableApprovalAffirmative(buttons);
|
|
2292
|
+
if (buttonIndex < 0 || !hasReliableConsentAnchor) {
|
|
2293
|
+
// No affirmative matched, or no decline / reliable grant option present
|
|
2294
|
+
// (→ not a real consent prompt, e.g. a picker that slipped past the
|
|
2295
|
+
// kind gate). Surface the modal so the user decides; never pick blindly.
|
|
2154
2296
|
return autoApproveActive;
|
|
2155
2297
|
}
|
|
2156
2298
|
// Modal *identity* signature — the question/button set only, NO volatile
|
|
@@ -2215,6 +2357,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2215
2357
|
this.autoApproveInactiveSince = 0;
|
|
2216
2358
|
// Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
|
|
2217
2359
|
this.autoApproveMaskSince = 0;
|
|
2360
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2218
2361
|
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
2219
2362
|
this.autoApproveBusyTimer = setTimeout(() => {
|
|
2220
2363
|
this.autoApproveBusy = false;
|
|
@@ -2396,6 +2539,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2396
2539
|
const previousStatus = this.lastStatus;
|
|
2397
2540
|
if (newStatus !== this.lastStatus) {
|
|
2398
2541
|
LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
|
|
2542
|
+
// COMPLETION-EARLYNOTIFY: snapshot every FSM status transition (the arm/fire/cancel
|
|
2543
|
+
// decisions downstream all hang off these edges). Guarded so production pays only a
|
|
2544
|
+
// boolean check; payload carries the visibility/auto-approve flags and the continuity
|
|
2545
|
+
// clocks (busyEpoch / lastOutputAt / lastScreenChangeAt) that the completion gate reads.
|
|
2546
|
+
if (this.fsmTraceOn()) this.recordFsmTransitionTrace({
|
|
2547
|
+
from: this.lastStatus,
|
|
2548
|
+
to: newStatus,
|
|
2549
|
+
rawStatus,
|
|
2550
|
+
autoApproveActive,
|
|
2551
|
+
autoApproveHoldIdle,
|
|
2552
|
+
autoApproveBusy: this.autoApproveBusy,
|
|
2553
|
+
hasPending: this.hasAdapterPendingResponse(),
|
|
2554
|
+
busyEpoch: this.busyEpoch,
|
|
2555
|
+
lastOutputAt: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
|
|
2556
|
+
lastScreenChangeAt: typeof adapterStatus?.lastScreenChangeAt === 'number' ? adapterStatus.lastScreenChangeAt : null,
|
|
2557
|
+
});
|
|
2399
2558
|
// GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
|
|
2400
2559
|
// is in 'starting' until its startup-grace settles to idle. When the FIRST inject
|
|
2401
2560
|
// lands inside that grace window, the adapter can report status DIRECTLY
|
|
@@ -2633,6 +2792,17 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2633
2792
|
if (this.isMeshWorkerSession()) {
|
|
2634
2793
|
traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
|
|
2635
2794
|
}
|
|
2795
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
|
|
2796
|
+
branch: 'short_generating',
|
|
2797
|
+
previousStatus: this.lastStatus,
|
|
2798
|
+
turnStartedAt: shortTurnStartedAt || null,
|
|
2799
|
+
busyEpochAtArm: this.busyEpoch,
|
|
2800
|
+
lastOutputAtArm: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
|
|
2801
|
+
flushDelay: NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
|
|
2802
|
+
evidenceSource: shortEvidenceSource,
|
|
2803
|
+
missingEvidence,
|
|
2804
|
+
hasFinalSummary: !!shortFinalSummary,
|
|
2805
|
+
});
|
|
2636
2806
|
this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
|
|
2637
2807
|
} else if (missingEvidence) {
|
|
2638
2808
|
// NON-MESH, missing evidence: suppress the completion event entirely (the
|
|
@@ -2721,6 +2891,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2721
2891
|
? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
|
|
2722
2892
|
: 3000;
|
|
2723
2893
|
LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
|
|
2894
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
|
|
2895
|
+
branch: 'normal',
|
|
2896
|
+
previousStatus: this.completedDebouncePending.previousStatus,
|
|
2897
|
+
turnStartedAt: this.completedDebouncePending.turnStartedAt ?? null,
|
|
2898
|
+
busyEpochAtArm: this.completedDebouncePending.busyEpochAtArm ?? null,
|
|
2899
|
+
lastOutputAtArm: this.completedDebouncePending.lastOutputAtArm ?? null,
|
|
2900
|
+
flushDelay,
|
|
2901
|
+
ownsExternalHistory,
|
|
2902
|
+
meshSettle: meshSettleSession,
|
|
2903
|
+
});
|
|
2724
2904
|
this.scheduleCompletedDebounceFlush(flushDelay);
|
|
2725
2905
|
}
|
|
2726
2906
|
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
@@ -3124,7 +3304,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3124
3304
|
}
|
|
3125
3305
|
|
|
3126
3306
|
/** @see ProviderInstance.noteManualInteraction */
|
|
3127
|
-
noteManualInteraction(now = Date.now()): void {
|
|
3307
|
+
noteManualInteraction(now = Date.now(), opts?: { passive?: boolean }): void {
|
|
3308
|
+
// P1b (#137 secondary): a DELEGATED worker session must not treat a
|
|
3309
|
+
// passive dashboard view (foreground tab selection / panel open) as
|
|
3310
|
+
// manual attendance. A coordinator merely peeking at a worker's panel
|
|
3311
|
+
// would otherwise suppress that worker's delegated auto-approve for the
|
|
3312
|
+
// whole 60s window. Only explicit input/intervention (controlbar,
|
|
3313
|
+
// resolve_action, pty_input) attends a worker. Non-worker (foreground)
|
|
3314
|
+
// sessions keep noting on passive views so a user foregrounding their own
|
|
3315
|
+
// session still holds auto-approve to act on the modal themselves.
|
|
3316
|
+
if (opts?.passive && this.isMeshWorkerSession()) return;
|
|
3128
3317
|
this.manualAttendance.note(now);
|
|
3129
3318
|
}
|
|
3130
3319
|
|
|
@@ -3154,6 +3343,50 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3154
3343
|
&& now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
|
|
3155
3344
|
}
|
|
3156
3345
|
|
|
3346
|
+
/**
|
|
3347
|
+
* NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
|
|
3348
|
+
* to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
|
|
3349
|
+
*
|
|
3350
|
+
* When auto-approve is configured but the episode never settles (modal parse miss / the
|
|
3351
|
+
* settle gate never satisfied), getState()/detectStatusTransition() fold the raw
|
|
3352
|
+
* `waiting_approval` into `generating` to suppress dashboard flicker — so
|
|
3353
|
+
* detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
|
|
3354
|
+
* event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
|
|
3355
|
+
* the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
|
|
3356
|
+
* intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
|
|
3357
|
+
* raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
|
|
3358
|
+
* instant the mask-stall threshold trips (the same moment getState un-folds the mask).
|
|
3359
|
+
*
|
|
3360
|
+
* Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
|
|
3361
|
+
* and its own dashboard mask already reveals the modal on stall. A normally-resolving
|
|
3362
|
+
* auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
|
|
3363
|
+
* masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
|
|
3364
|
+
* discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
|
|
3365
|
+
* mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
|
|
3366
|
+
*/
|
|
3367
|
+
private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
|
|
3368
|
+
if (!this.isMeshWorkerSession()) return;
|
|
3369
|
+
if (adapterStatus?.status !== 'waiting_approval') return;
|
|
3370
|
+
if (!this.autoApproveMaskStalled(now)) return;
|
|
3371
|
+
// Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
|
|
3372
|
+
if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
|
|
3373
|
+
this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
|
|
3374
|
+
const modal = adapterStatus.activeModal;
|
|
3375
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
3376
|
+
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
3377
|
+
this.appendRuntimeSystemMessage(
|
|
3378
|
+
this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
|
|
3379
|
+
`approval_request:${now}`,
|
|
3380
|
+
now,
|
|
3381
|
+
);
|
|
3382
|
+
this.pushEvent({
|
|
3383
|
+
event: 'agent:waiting_approval', chatTitle, timestamp: now,
|
|
3384
|
+
modalMessage: modal?.message,
|
|
3385
|
+
modalButtons: modal?.buttons,
|
|
3386
|
+
});
|
|
3387
|
+
LOG.info('CLI', `[${this.type}] stalled auto-approve nudge → coordinator (masked ${Math.round((now - this.autoApproveMaskSince) / 1000)}s)`);
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3157
3390
|
private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
|
|
3158
3391
|
this.appendRuntimeSystemMessage(
|
|
3159
3392
|
formatAutoApprovalMessage(modalMessage, buttonLabel),
|
|
@@ -83,3 +83,23 @@ export const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
83
83
|
'resolve_action',
|
|
84
84
|
'pty_input',
|
|
85
85
|
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The subset of {@link MANUAL_ATTENDANCE_COMMANDS} that are PASSIVE view-only
|
|
89
|
+
* actions — foregrounding a session's tab / opening its panel. They convey "I am
|
|
90
|
+
* looking at this session", not "I am driving it", and carry no user input.
|
|
91
|
+
*
|
|
92
|
+
* For a foreground (base-node) session these still attend: a user who
|
|
93
|
+
* foregrounds their own session should get the quiet window so an incoming
|
|
94
|
+
* approval stays visible for them to act on. But for a DELEGATED worker session
|
|
95
|
+
* a passive peek must NOT attend — a coordinator merely opening a worker's panel
|
|
96
|
+
* to watch progress would otherwise suppress that worker's delegated
|
|
97
|
+
* auto-approve for the whole window (secondary cause, #137). The per-instance
|
|
98
|
+
* hook decides: it drops a passive stamp only when the session is a delegated
|
|
99
|
+
* worker, so explicit input (controlbar / resolve_action / pty_input) still
|
|
100
|
+
* attends a worker and a foreground session is unaffected.
|
|
101
|
+
*/
|
|
102
|
+
export const MANUAL_ATTENDANCE_PASSIVE_VIEW_COMMANDS: ReadonlySet<string> = new Set([
|
|
103
|
+
'select_session',
|
|
104
|
+
'open_panel',
|
|
105
|
+
]);
|
|
@@ -232,8 +232,13 @@ export interface ProviderInstance {
|
|
|
232
232
|
* input). Provider-common signal that suppresses auto-approve for a short
|
|
233
233
|
* window so the user can drive the session manually; background mesh worker
|
|
234
234
|
* sessions never receive it, so their delegated auto-approve is unaffected.
|
|
235
|
+
*
|
|
236
|
+
* `opts.passive` marks a view-only action (select_session / open_panel). A
|
|
237
|
+
* delegated worker session ignores passive stamps so a coordinator merely
|
|
238
|
+
* watching its panel does not suppress its delegated auto-approve; explicit
|
|
239
|
+
* input still attends. Foreground sessions attend on passive views too.
|
|
235
240
|
*/
|
|
236
|
-
noteManualInteraction?(now?: number): void;
|
|
241
|
+
noteManualInteraction?(now?: number, opts?: { passive?: boolean }): void;
|
|
237
242
|
|
|
238
243
|
/** cleanup */
|
|
239
244
|
dispose(): void;
|