@adhdev/daemon-core 0.9.82-rc.456 → 0.9.82-rc.458

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.
@@ -1,6 +1,7 @@
1
1
  import type { GitRepoStatus, GitSubmoduleStatus } from '../git/git-types.js';
2
2
  import { getGitRepoStatus } from '../git/git-status.js';
3
3
  import { GitCommandError, runGit } from '../git/git-executor.js';
4
+ import { resolveSubmoduleDefaultBranch } from './worktree-bootstrap-config.js';
4
5
 
5
6
  export interface MeshFastForwardNodeArgs {
6
7
  nodeId?: string;
@@ -521,31 +522,43 @@ async function resolveSubmodulePushes(
521
522
  results.push({ ...base, code: 'submodule_status_incomplete' });
522
523
  continue;
523
524
  }
524
- // Refresh the submodule's origin/main, then require it to be an ancestor of
525
+ // Generalize the submodule's default branch (F18): '.gitmodules' branch
526
+ // local remote HEAD → remote-advertised HEAD → 'main'. On a main-default
527
+ // submodule this resolves to 'main', keeping every ref below byte-identical.
528
+ const remoteBranch = await resolveSubmoduleDefaultBranch({
529
+ submoduleRepoPath: repoPath,
530
+ superprojectWorkspace: status.repoRoot ?? status.workspace,
531
+ submodulePath: submodule.path,
532
+ timeoutMs,
533
+ });
534
+ base.remoteBranch = remoteBranch;
535
+ const remoteRef = `refs/remotes/origin/${remoteBranch}`;
536
+ const fetchRefspec = `refs/heads/${remoteBranch}:${remoteRef}`;
537
+ // Refresh the submodule's origin/<branch>, then require it to be an ancestor of
525
538
  // the gitlink commit (strict ff-only).
526
539
  try {
527
- await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
540
+ await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', fetchRefspec], { timeoutMs: timeoutMs ?? 30_000 });
528
541
  } catch (error) {
529
542
  results.push({ ...base, code: 'submodule_fetch_failed', error: formatGitError(error) });
530
543
  continue;
531
544
  }
532
545
  let alreadyReachable = false;
533
546
  try {
534
- await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
547
+ await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, remoteRef], { timeoutMs: timeoutMs ?? 15_000 });
535
548
  alreadyReachable = true;
536
- } catch { /* not yet on origin/main — candidate for push */ }
549
+ } catch { /* not yet on origin/<branch> — candidate for push */ }
537
550
  if (alreadyReachable) {
538
551
  results.push({ ...base, pushed: false, skipped: true, code: 'submodule_already_reachable' });
539
552
  continue;
540
553
  }
541
- // Strict ff-only: origin/main must be an ancestor of the commit we publish.
554
+ // Strict ff-only: origin/<branch> must be an ancestor of the commit we publish.
542
555
  try {
543
- await runGit(repoPath, ['merge-base', '--is-ancestor', 'refs/remotes/origin/main', submodule.commit], { timeoutMs: timeoutMs ?? 15_000 });
556
+ await runGit(repoPath, ['merge-base', '--is-ancestor', remoteRef, submodule.commit], { timeoutMs: timeoutMs ?? 15_000 });
544
557
  } catch (error) {
545
558
  results.push({ ...base, pushed: false, skipped: false, code: 'submodule_non_fast_forward', error: formatGitError(error) });
546
559
  continue;
547
560
  }
548
- const refspec = `${submodule.commit}:refs/heads/main`;
561
+ const refspec = `${submodule.commit}:refs/heads/${remoteBranch}`;
549
562
  if (!execute) {
550
563
  results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_available', refspec });
551
564
  continue;
@@ -553,8 +566,8 @@ async function resolveSubmodulePushes(
553
566
  try {
554
567
  await runGit(repoPath, ['push', 'origin', refspec], { timeoutMs: timeoutMs ?? 30_000 });
555
568
  // Verify reachability after the push.
556
- await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
557
- await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
569
+ await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', fetchRefspec], { timeoutMs: timeoutMs ?? 30_000 });
570
+ await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, remoteRef], { timeoutMs: timeoutMs ?? 15_000 });
558
571
  results.push({ ...base, pushed: true, skipped: false, code: 'submodule_pushed', refspec });
559
572
  } catch (error) {
560
573
  results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_failed', refspec, error: formatGitError(error) });
@@ -16,7 +16,7 @@ import { getGitRepoStatus } from '../git/git-status.js';
16
16
  import * as yaml from 'js-yaml';
17
17
  import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
18
18
  import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
19
- import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap } from '../mesh/worktree-bootstrap-config.js';
19
+ import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap, resolveSubmoduleDefaultBranch } from '../mesh/worktree-bootstrap-config.js';
20
20
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
21
21
  import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
22
22
  import * as fs from 'fs';
@@ -1295,6 +1295,10 @@ export async function runMeshRefineSubmoduleReachabilityGate(
1295
1295
  commit: gitlink.commit,
1296
1296
  reachable: false,
1297
1297
  };
1298
+ // Resolved lazily once the submodule checkout/remote are confirmed; defaults
1299
+ // to 'main' so error messages emitted before resolution stay byte-identical
1300
+ // to the pre-generalization behavior on a main-default repo.
1301
+ let submoduleDefaultBranch = 'main';
1298
1302
  try {
1299
1303
  if (!fs.existsSync(submodulePath)) {
1300
1304
  entry.error = `Submodule checkout missing at ${gitlink.path}`;
@@ -1350,9 +1354,18 @@ export async function runMeshRefineSubmoduleReachabilityGate(
1350
1354
  entries.push(entry);
1351
1355
  continue;
1352
1356
  }
1353
- entry.remoteMainBranch = 'main';
1357
+ // Generalize the submodule's default branch (F18): '.gitmodules'
1358
+ // branch → local remote HEAD → remote-advertised HEAD → 'main'. On a
1359
+ // main-default submodule this resolves to 'main' and every ref target
1360
+ // below is byte-identical to the prior hardcoded path.
1361
+ submoduleDefaultBranch = await resolveSubmoduleDefaultBranch({
1362
+ submoduleRepoPath: submodulePath,
1363
+ superprojectWorkspace: repoRoot,
1364
+ submodulePath: gitlink.path,
1365
+ });
1366
+ entry.remoteMainBranch = submoduleDefaultBranch;
1354
1367
  try {
1355
- await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, 'main');
1368
+ await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, submoduleDefaultBranch);
1356
1369
  entry.fetchedFromOrigin = true;
1357
1370
  entry.remoteReachable = true;
1358
1371
  entry.remoteMainReachable = true;
@@ -1362,17 +1375,17 @@ export async function runMeshRefineSubmoduleReachabilityGate(
1362
1375
  entry.remoteMainReachable = false;
1363
1376
  entry.publishRequired = true;
1364
1377
  const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
1365
- entry.error = `Submodule remote main reachability check failed for origin/main: ${details}`;
1378
+ entry.error = `Submodule remote main reachability check failed for origin/${submoduleDefaultBranch}: ${details}`;
1366
1379
  if (options.allowAutoPublishSubmoduleMainCommits === true && entry.localReachable === true) {
1367
1380
  entry.autoPublishAllowed = true;
1368
1381
  entry.autoPublishAttempted = true;
1369
1382
  try {
1370
- const publish = await publishCommitToRemoteMain(submodulePath, gitlink.commit, 'main');
1383
+ const publish = await publishCommitToRemoteMain(submodulePath, gitlink.commit, submoduleDefaultBranch);
1371
1384
  entry.autoPublishRefspec = publish.refspec;
1372
1385
  entry.publishStdout = truncateValidationOutput(publish.stdout);
1373
1386
  entry.publishStderr = truncateValidationOutput(publish.stderr);
1374
1387
  entry.autoPublishSucceeded = true;
1375
- await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, 'main');
1388
+ await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, submoduleDefaultBranch);
1376
1389
  entry.fetchedFromOrigin = true;
1377
1390
  entry.remoteReachable = true;
1378
1391
  entry.remoteMainReachable = true;
@@ -1384,13 +1397,13 @@ export async function runMeshRefineSubmoduleReachabilityGate(
1384
1397
  entry.autoPublishSucceeded = false;
1385
1398
  entry.autoPublishVerified = false;
1386
1399
  const publishDetails = truncateValidationOutput(publishError?.stderr || publishError?.message || String(publishError));
1387
- entry.error = `Submodule auto-publish to origin/main failed or could not be verified: ${publishDetails}`;
1400
+ entry.error = `Submodule auto-publish to origin/${submoduleDefaultBranch} failed or could not be verified: ${publishDetails}`;
1388
1401
  }
1389
1402
  } else if (options.allowAutoPublishSubmoduleMainCommits === true) {
1390
1403
  entry.autoPublishAllowed = true;
1391
1404
  entry.autoPublishAttempted = false;
1392
1405
  entry.autoPublishSkippedReason = entry.autoPublishSkippedReason
1393
- || 'candidate commit is not reachable in the source checkout or worktree submodule, so Refinery cannot push it to origin/main';
1406
+ || `candidate commit is not reachable in the source checkout or worktree submodule, so Refinery cannot push it to origin/${submoduleDefaultBranch}`;
1394
1407
  }
1395
1408
  }
1396
1409
  } catch (e: any) {
@@ -1398,7 +1411,7 @@ export async function runMeshRefineSubmoduleReachabilityGate(
1398
1411
  entry.remoteMainReachable = false;
1399
1412
  entry.publishRequired = true;
1400
1413
  const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
1401
- entry.error = `Submodule remote main reachability check failed for origin/main: ${details}`;
1414
+ entry.error = `Submodule remote main reachability check failed for origin/${submoduleDefaultBranch}: ${details}`;
1402
1415
  }
1403
1416
  } catch (e: any) {
1404
1417
  entry.error = truncateValidationOutput(e?.message || String(e));
@@ -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(
@@ -92,6 +92,136 @@ function getRegisteredSubmodulePaths(workspace: string): Set<string> {
92
92
  return paths;
93
93
  }
94
94
 
95
+ /**
96
+ * Read each registered submodule's configured `branch` from `.gitmodules`, keyed
97
+ * by the submodule's normalized path (matching {@link getRegisteredSubmodulePaths}).
98
+ *
99
+ * `.gitmodules` stores `submodule.<name>.path` and (optionally)
100
+ * `submodule.<name>.branch`; this joins the two on `<name>`. The special branch
101
+ * value `.` ("track the superproject's branch") is deliberately OMITTED so callers
102
+ * fall through to remote-HEAD detection instead of treating `.` as a literal branch
103
+ * name. Returns an empty map when there are no submodules, no `.gitmodules`, or the
104
+ * lookup fails (conservative — callers then detect or fall back).
105
+ */
106
+ export function getSubmoduleConfiguredBranches(workspace: string): Map<string, string> {
107
+ const branchesByPath = new Map<string, string>();
108
+ try {
109
+ const out = execFileSync(
110
+ resolveWin32Executable('git'),
111
+ ['config', '--file', '.gitmodules', '--list'],
112
+ { cwd: workspace, encoding: 'utf8', timeout: 10_000, windowsHide: true },
113
+ );
114
+ // Join `submodule.<name>.path` with `submodule.<name>.branch` on <name>.
115
+ const pathByName = new Map<string, string>();
116
+ const branchByName = new Map<string, string>();
117
+ for (const line of String(out).split(/\r?\n/)) {
118
+ const trimmed = line.trim();
119
+ if (!trimmed) continue;
120
+ const eq = trimmed.indexOf('=');
121
+ if (eq < 0) continue;
122
+ const key = trimmed.slice(0, eq);
123
+ const value = trimmed.slice(eq + 1).trim();
124
+ // key: submodule.<name>.<field>; <name> may itself contain dots, so match
125
+ // the leading `submodule.` and trailing `.<field>` and take the middle.
126
+ const match = /^submodule\.(.+)\.(path|branch)$/.exec(key);
127
+ if (!match) continue;
128
+ const name = match[1];
129
+ if (match[2] === 'path') {
130
+ const norm = value.replace(/\\/g, '/').replace(/\/+$/, '');
131
+ if (norm) pathByName.set(name, norm);
132
+ } else if (value) {
133
+ branchByName.set(name, value);
134
+ }
135
+ }
136
+ for (const [name, submodulePath] of pathByName) {
137
+ const branch = branchByName.get(name);
138
+ if (branch && branch !== '.') branchesByPath.set(submodulePath, branch);
139
+ }
140
+ } catch {
141
+ // No .gitmodules / git error → no configured branches.
142
+ }
143
+ return branchesByPath;
144
+ }
145
+
146
+ /** Fallback submodule branch when no configured/detected default can be resolved. */
147
+ export const SUBMODULE_DEFAULT_BRANCH_FALLBACK = 'main';
148
+
149
+ function isPlausibleBranchName(name: unknown): name is string {
150
+ return typeof name === 'string' && name.length > 0 && !/\s/.test(name) && name !== 'HEAD';
151
+ }
152
+
153
+ /**
154
+ * Resolve the default branch a submodule's commits are published to / checked for
155
+ * reachability against. Generalizes the previously hardcoded `main` so a submodule
156
+ * whose default branch is `master`/`trunk`/etc. is handled. Priority (each tier
157
+ * falls through to the next on miss/error):
158
+ *
159
+ * 1. `.gitmodules` `submodule.<name>.branch` (via {@link getSubmoduleConfiguredBranches};
160
+ * `.` is ignored) — an explicit, local, zero-cost declaration.
161
+ * 2. the submodule checkout's LOCAL remote HEAD: `git symbolic-ref --short
162
+ * refs/remotes/<remote>/HEAD` → strip the `<remote>/` prefix (no network).
163
+ * 3. the submodule remote's advertised HEAD: `git ls-remote --symref <remote> HEAD`
164
+ * → `ref: refs/heads/<branch>` (one network round-trip).
165
+ * 4. fallback {@link SUBMODULE_DEFAULT_BRANCH_FALLBACK} (`'main'`).
166
+ *
167
+ * Because the final fallback is `'main'` and every earlier tier that resolves `'main'`
168
+ * yields the same string, a repo whose submodules default to `main` (the common case)
169
+ * produces byte-identical downstream fetch/merge-base/push ref targets — only a
170
+ * read-only resolution probe is added.
171
+ */
172
+ export async function resolveSubmoduleDefaultBranch(opts: {
173
+ /** The submodule's local checkout — cwd for symbolic-ref / ls-remote. */
174
+ submoduleRepoPath: string;
175
+ /** The superproject workspace — for the `.gitmodules` branch lookup (tier 1). */
176
+ superprojectWorkspace?: string;
177
+ /** The submodule's path relative to the superproject (key into `.gitmodules`). */
178
+ submodulePath?: string;
179
+ /** Remote name (default `origin`). */
180
+ remote?: string;
181
+ /** Timeout for the local probe (tier 2); the network probe (tier 3) gets max(this, 30s). */
182
+ timeoutMs?: number;
183
+ }): Promise<string> {
184
+ const remote = opts.remote?.trim() || 'origin';
185
+ const localTimeout = opts.timeoutMs ?? 10_000;
186
+ const git = resolveWin32Executable('git');
187
+ const execFileAsync = promisify(execFile);
188
+
189
+ // Tier 1: .gitmodules configured branch (local, zero-cost).
190
+ if (opts.superprojectWorkspace && opts.submodulePath) {
191
+ try {
192
+ const normalized = opts.submodulePath.replace(/\\/g, '/').replace(/\/+$/, '');
193
+ const configured = getSubmoduleConfiguredBranches(opts.superprojectWorkspace).get(normalized);
194
+ if (isPlausibleBranchName(configured)) return configured;
195
+ } catch { /* fall through */ }
196
+ }
197
+
198
+ // Tier 2: the local remote HEAD (no network) — set by clone/`git remote set-head`.
199
+ try {
200
+ const { stdout } = await execFileAsync(
201
+ git,
202
+ ['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`],
203
+ { cwd: opts.submoduleRepoPath, encoding: 'utf8', timeout: localTimeout, windowsHide: true },
204
+ );
205
+ const short = String(stdout || '').trim();
206
+ const prefix = `${remote}/`;
207
+ const branch = short.startsWith(prefix) ? short.slice(prefix.length) : short;
208
+ if (isPlausibleBranchName(branch)) return branch;
209
+ } catch { /* fall through */ }
210
+
211
+ // Tier 3: the remote's advertised HEAD (one network round-trip).
212
+ try {
213
+ const { stdout } = await execFileAsync(
214
+ git,
215
+ ['ls-remote', '--symref', remote, 'HEAD'],
216
+ { cwd: opts.submoduleRepoPath, encoding: 'utf8', timeout: Math.max(localTimeout, 30_000), windowsHide: true },
217
+ );
218
+ const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD/m.exec(String(stdout || ''));
219
+ if (match && isPlausibleBranchName(match[1])) return match[1];
220
+ } catch { /* fall through */ }
221
+
222
+ return SUBMODULE_DEFAULT_BRANCH_FALLBACK;
223
+ }
224
+
95
225
  /**
96
226
  * True when `git status --porcelain` output represents a worktree that is clean
97
227
  * EXCEPT for submodule-gitlink-pointer moves. A worktree task that commits inside a
@@ -22,6 +22,8 @@ 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';
@@ -1526,9 +1528,23 @@ export class CliProviderInstance implements ProviderInstance {
1526
1528
  }
1527
1529
 
1528
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();
1529
1545
  if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1530
1546
  return {
1531
- present: true,
1547
+ present: turnClosed,
1532
1548
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1533
1549
  source: 'parsed',
1534
1550
  };
@@ -1537,7 +1553,7 @@ export class CliProviderInstance implements ProviderInstance {
1537
1553
  const externalMessages = this.readExternalCompletionMessages();
1538
1554
  if (externalMessages) {
1539
1555
  return {
1540
- present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1556
+ present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1541
1557
  messages: externalMessages,
1542
1558
  source: 'external-native',
1543
1559
  };
@@ -1690,11 +1706,21 @@ export class CliProviderInstance implements ProviderInstance {
1690
1706
 
1691
1707
  const adapterAny = this.adapter as any;
1692
1708
  const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1693
- if (!approvalResolvedIdle) {
1694
- if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1695
- if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1696
- if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1697
- }
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 };
1698
1724
 
1699
1725
  const partial = typeof this.adapter.getPartialResponse === 'function'
1700
1726
  ? this.adapter.getPartialResponse()
@@ -1891,6 +1917,40 @@ export class CliProviderInstance implements ProviderInstance {
1891
1917
  };
1892
1918
  }
1893
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
+
1894
1954
  private flushCompletedDebounceIfFinalized(): void {
1895
1955
  const pending = this.completedDebouncePending;
1896
1956
  if (!pending) {
@@ -1904,6 +1964,13 @@ export class CliProviderInstance implements ProviderInstance {
1904
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?.()}`);
1905
1965
  if (latestVisibleStatus !== 'idle') {
1906
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
+ });
1907
1974
  this.completedDebouncePending = null;
1908
1975
  this.completedDebounceTimer = null;
1909
1976
  return;
@@ -1921,6 +1988,14 @@ export class CliProviderInstance implements ProviderInstance {
1921
1988
  // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
1922
1989
  if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
1923
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
+ });
1924
1999
  this.completedDebouncePending = null;
1925
2000
  this.completedDebounceTimer = null;
1926
2001
  return;
@@ -1930,6 +2005,14 @@ export class CliProviderInstance implements ProviderInstance {
1930
2005
  && typeof latestOutputAt === 'number'
1931
2006
  && latestOutputAt > pending.lastOutputAtArm) {
1932
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
+ });
1933
2016
  this.completedDebouncePending = null;
1934
2017
  this.completedDebounceTimer = null;
1935
2018
  return;
@@ -1975,6 +2058,17 @@ export class CliProviderInstance implements ProviderInstance {
1975
2058
  if (this.isMeshWorkerSession()) {
1976
2059
  traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1977
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
+ });
1978
2072
  pending.loggedBlockReason = blockReason;
1979
2073
  }
1980
2074
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
@@ -1997,6 +2091,19 @@ export class CliProviderInstance implements ProviderInstance {
1997
2091
  if (this.isMeshWorkerSession()) {
1998
2092
  traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1999
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
+ });
2000
2107
  this.pushEvent({
2001
2108
  event: 'agent:generating_completed',
2002
2109
  chatTitle: pending.chatTitle,
@@ -2028,6 +2135,14 @@ export class CliProviderInstance implements ProviderInstance {
2028
2135
  if (this.isMeshWorkerSession()) {
2029
2136
  traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
2030
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
+ });
2031
2146
  this.pushEvent({
2032
2147
  event: 'agent:generating_completed',
2033
2148
  chatTitle: pending.chatTitle,
@@ -2424,6 +2539,22 @@ export class CliProviderInstance implements ProviderInstance {
2424
2539
  const previousStatus = this.lastStatus;
2425
2540
  if (newStatus !== this.lastStatus) {
2426
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
+ });
2427
2558
  // GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
2428
2559
  // is in 'starting' until its startup-grace settles to idle. When the FIRST inject
2429
2560
  // lands inside that grace window, the adapter can report status DIRECTLY
@@ -2661,6 +2792,17 @@ export class CliProviderInstance implements ProviderInstance {
2661
2792
  if (this.isMeshWorkerSession()) {
2662
2793
  traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
2663
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
+ });
2664
2806
  this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
2665
2807
  } else if (missingEvidence) {
2666
2808
  // NON-MESH, missing evidence: suppress the completion event entirely (the
@@ -2749,6 +2891,16 @@ export class CliProviderInstance implements ProviderInstance {
2749
2891
  ? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2750
2892
  : 3000;
2751
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
+ });
2752
2904
  this.scheduleCompletedDebounceFlush(flushDelay);
2753
2905
  }
2754
2906
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {