@adhdev/daemon-core 0.9.82-rc.553 → 0.9.82-rc.554
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-adapter-types.d.ts +29 -0
- package/dist/index.js +341 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +341 -11
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-refine-gates.d.ts +86 -0
- package/dist/providers/cli-provider-instance.d.ts +16 -1
- package/dist/providers/spec/adapter.d.ts +7 -0
- package/dist/providers/spec/cli-adapter.d.ts +64 -0
- package/dist/providers/spec/fsm-driver.d.ts +11 -0
- package/package.json +3 -3
- package/src/cli-adapter-types.ts +29 -0
- package/src/commands/router-refine.ts +40 -1
- package/src/mesh/mesh-refine-gates.ts +256 -0
- package/src/providers/cli-provider-instance.ts +83 -8
- package/src/providers/spec/adapter.ts +7 -0
- package/src/providers/spec/cli-adapter.ts +122 -0
- package/src/providers/spec/fsm-driver.ts +5 -0
|
@@ -327,6 +327,92 @@ export declare function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any
|
|
|
327
327
|
source?: string;
|
|
328
328
|
};
|
|
329
329
|
export declare function runMeshRefinePatchEquivalenceGate(repoRoot: string, baseHead: string, branchHead: string): Promise<MeshRefinePatchEquivalenceSummary>;
|
|
330
|
+
/**
|
|
331
|
+
* Machine-readable sub-classification of a `patch_equivalence_failed` (and the
|
|
332
|
+
* related submodule-gitlink preflight blocks). The opaque top-level
|
|
333
|
+
* `patch_equivalence_failed` code is preserved for backward compatibility; this
|
|
334
|
+
* detailed reason is added ALONGSIDE it so coordinators no longer have to guess
|
|
335
|
+
* WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
|
|
336
|
+
* the real cause was a diverged base + an unreachable submodule gitlink artifact,
|
|
337
|
+
* not a real patch conflict, but Refinery only returned the opaque code and the
|
|
338
|
+
* coordinator mis-attributed it to a stale daemon version).
|
|
339
|
+
*/
|
|
340
|
+
export type MeshRefinePatchEquivalenceDetailedReasonCode =
|
|
341
|
+
/** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
|
|
342
|
+
'base_divergence'
|
|
343
|
+
/** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
|
|
344
|
+
| 'submodule_unreachable'
|
|
345
|
+
/** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
|
|
346
|
+
| 'actual_patch_diff'
|
|
347
|
+
/** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
|
|
348
|
+
| 'trivial_ff_misjudgment'
|
|
349
|
+
/** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
|
|
350
|
+
| 'already_converged'
|
|
351
|
+
/** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
|
|
352
|
+
| 'unclassified';
|
|
353
|
+
export type MeshRefinePatchEquivalenceFailureClassification = {
|
|
354
|
+
detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
|
|
355
|
+
/** Human-readable one-line description of the sub-cause. */
|
|
356
|
+
detailedReasonDescription: string;
|
|
357
|
+
/** Suggested next action for the coordinator/owner (free-form, actionable). */
|
|
358
|
+
recommendedAction: string;
|
|
359
|
+
/** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
|
|
360
|
+
evidence: {
|
|
361
|
+
baseHead?: string;
|
|
362
|
+
branchHead?: string;
|
|
363
|
+
mergeBase?: string;
|
|
364
|
+
/** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
|
|
365
|
+
behind?: number;
|
|
366
|
+
/** How many commits the branch is ahead of the merge-base. */
|
|
367
|
+
ahead?: number;
|
|
368
|
+
/** True when HEAD is NOT a descendant of the target base (diverged). */
|
|
369
|
+
baseDiverged?: boolean;
|
|
370
|
+
expectedPatchId?: string;
|
|
371
|
+
actualPatchId?: string;
|
|
372
|
+
patchIdEqual?: boolean;
|
|
373
|
+
/** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
|
|
374
|
+
diffStat?: string;
|
|
375
|
+
/** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
|
|
376
|
+
submoduleGitlinks?: Array<{
|
|
377
|
+
path: string;
|
|
378
|
+
baseCommit?: string;
|
|
379
|
+
branchCommit?: string;
|
|
380
|
+
/** True when branchCommit descends baseCommit (a strict fast-forward advance). */
|
|
381
|
+
fastForward?: boolean;
|
|
382
|
+
/** True when branchCommit is reachable from the submodule's local origin/main. */
|
|
383
|
+
reachableFromOriginMain?: boolean;
|
|
384
|
+
}>;
|
|
385
|
+
/** Effective auto-publish-submodule-main-commits policy value at classification time. */
|
|
386
|
+
autoPublishSubmoduleMainCommits?: boolean;
|
|
387
|
+
/** Set when the classifier itself errored (detailedReason === 'unclassified'). */
|
|
388
|
+
classifierError?: string;
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Classify WHY a patch-equivalence preflight blocked, turning the opaque
|
|
393
|
+
* `patch_equivalence_failed` code into a machine-readable {@link
|
|
394
|
+
* MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
|
|
395
|
+
* structured evidence. Read-only: runs only `git` inspection commands (rev-list,
|
|
396
|
+
* merge-base, diff --stat, submodule reachability probes) against the already-set
|
|
397
|
+
* worktree — it never mutates the repo.
|
|
398
|
+
*
|
|
399
|
+
* Priority of classification (first match wins):
|
|
400
|
+
* 1. already_converged — ahead 0 & behind 0 & no residual diff
|
|
401
|
+
* 2. submodule_unreachable — a changed gitlink commit is not reachable from the
|
|
402
|
+
* submodule's origin/main (publish needed)
|
|
403
|
+
* 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
|
|
404
|
+
* patch-ids match — blocked only by a ff gitlink
|
|
405
|
+
* 4. base_divergence — HEAD is not a descendant of the target base
|
|
406
|
+
* 5. actual_patch_diff — genuine content divergence (the residual case)
|
|
407
|
+
*
|
|
408
|
+
* `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
|
|
409
|
+
* or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
|
|
410
|
+
* the submodule_unreachable recommendation can name the current policy value.
|
|
411
|
+
*/
|
|
412
|
+
export declare function classifyPatchEquivalenceFailure(repoRoot: string, baseHead: string, branchHead: string, summary: MeshRefinePatchEquivalenceSummary, options?: {
|
|
413
|
+
targetBaseRef?: string;
|
|
414
|
+
autoPublishSubmoduleMainCommits?: boolean;
|
|
415
|
+
}): Promise<MeshRefinePatchEquivalenceFailureClassification>;
|
|
330
416
|
export type MeshWorktreePatchContainmentSummary = {
|
|
331
417
|
/** True only when merging worktreeHead into ref introduces no new patch. */
|
|
332
418
|
contained: boolean;
|
|
@@ -423,6 +423,21 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
423
423
|
* the dashboard tail-repair cache — a display value, not a completion decision.
|
|
424
424
|
*/
|
|
425
425
|
private lastVisibleAssistantSummary;
|
|
426
|
+
/**
|
|
427
|
+
* NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
|
|
428
|
+
* cached for the current turn (lastCompletionSummary), if any. The evidence
|
|
429
|
+
* probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
|
|
430
|
+
* provider (antigravity) the parsed screen and the native transcript can both
|
|
431
|
+
* momentarily yield no in-turn final assistant at the exact instant the
|
|
432
|
+
* completion gate fires — source='unavailable', missingEvidence=true — even
|
|
433
|
+
* though a prior poll already read the real answer off native-history and cached
|
|
434
|
+
* it here (the same value mesh_read_chat.summary shows). Consulting the cache at
|
|
435
|
+
* emit time lets that already-secured summary count as evidence, so the completion
|
|
436
|
+
* notification carries the answer instead of completion_diagnostic=missing_final_assistant
|
|
437
|
+
* with an empty summary. Returns '' when the cache is empty or was reset by the
|
|
438
|
+
* next turn (see lastCompletionSummary = null on onTurnStarted).
|
|
439
|
+
*/
|
|
440
|
+
private cachedCompletionSummaryContent;
|
|
426
441
|
private completionFinalAssistantEvidence;
|
|
427
442
|
private completionFinalSummary;
|
|
428
443
|
private buildCompletedFinalizationDiagnostic;
|
|
@@ -482,7 +497,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
482
497
|
bytes: number;
|
|
483
498
|
} | {
|
|
484
499
|
ok: false;
|
|
485
|
-
refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker';
|
|
500
|
+
refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported';
|
|
486
501
|
keys: MeshSendKeyName[];
|
|
487
502
|
hasDestructive: boolean;
|
|
488
503
|
}>;
|
|
@@ -79,6 +79,13 @@ export declare class TerminalAdapter {
|
|
|
79
79
|
row: number;
|
|
80
80
|
col: number;
|
|
81
81
|
};
|
|
82
|
+
/** Current terminal geometry (columns × rows). Tracked here rather than
|
|
83
|
+
* read off the screen buffer so a resize is reflected immediately, before
|
|
84
|
+
* the next repaint. Consumed by the mesh_read_terminal viewport read. */
|
|
85
|
+
getScreenSize(): {
|
|
86
|
+
cols: number;
|
|
87
|
+
rows: number;
|
|
88
|
+
};
|
|
82
89
|
send_keys(text: string): void;
|
|
83
90
|
/** Forward runtime metadata (meshNodeId, workspaceLabel, lifecycle, …) to
|
|
84
91
|
* the underlying transport so it reaches the session registry. The spec
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
|
|
2
2
|
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
3
|
+
import { type MeshSendKeyItem, type MeshSendKeyName } from '../../cli-adapters/provider-cli-shared.js';
|
|
3
4
|
import { type InteractivePromptResponse } from '../types/interactive-prompt.js';
|
|
4
5
|
export declare class SpecCliAdapter implements CliAdapter {
|
|
5
6
|
readonly cliType: string;
|
|
@@ -74,6 +75,69 @@ export declare class SpecCliAdapter implements CliAdapter {
|
|
|
74
75
|
cancel(): void;
|
|
75
76
|
isProcessing(): boolean;
|
|
76
77
|
isReady(): boolean;
|
|
78
|
+
isAlive(): boolean;
|
|
79
|
+
private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES;
|
|
80
|
+
private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES;
|
|
81
|
+
/**
|
|
82
|
+
* MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
|
|
83
|
+
* of the CURRENT rendered viewport for mesh_read_terminal on the spec path
|
|
84
|
+
* (claude-cli / antigravity / codex-cli — the native-source providers that
|
|
85
|
+
* route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
|
|
86
|
+
* - returns ONLY the driver's current viewport snapshot, the cursor
|
|
87
|
+
* position and the terminal geometry — NO scrollback, NO parser/FSM
|
|
88
|
+
* state, NO debug buffers;
|
|
89
|
+
* - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
|
|
90
|
+
* screen of multi-byte glyphs can never exceed the MCP payload cap;
|
|
91
|
+
* - `hash` is over the FULL untruncated viewport so a caller can detect a
|
|
92
|
+
* screen change across polls even when the returned text was truncated.
|
|
93
|
+
*
|
|
94
|
+
* SECURITY: the raw viewport can carry tokens / command args / env / user
|
|
95
|
+
* data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
|
|
96
|
+
*/
|
|
97
|
+
getTerminalScreenSnapshot(maxBytes?: number): {
|
|
98
|
+
text: string;
|
|
99
|
+
cursor: {
|
|
100
|
+
col: number;
|
|
101
|
+
row: number;
|
|
102
|
+
};
|
|
103
|
+
cols: number;
|
|
104
|
+
rows: number;
|
|
105
|
+
truncated: boolean;
|
|
106
|
+
originalBytes: number;
|
|
107
|
+
returnedBytes: number;
|
|
108
|
+
hash: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
|
|
112
|
+
* sequence into the spec-driven PTY for mesh_send_keys. Mirrors
|
|
113
|
+
* ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
|
|
114
|
+
* whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
|
|
115
|
+
* contiguous string, so a submit key can never be separated from the text
|
|
116
|
+
* it submits).
|
|
117
|
+
*
|
|
118
|
+
* The spec path drives the child through the FsmDriver, not a directly-held
|
|
119
|
+
* ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
|
|
120
|
+
* against here (the driver serializes its own writes), so the only guard is
|
|
121
|
+
* the modal fail-closed: a NON-destructive injection into an actionable
|
|
122
|
+
* approval modal is refused (use mesh_approve) unless explicitly overridden.
|
|
123
|
+
* A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
|
|
124
|
+
* past this gate (the tool layer owns the destructive double-gate + audit).
|
|
125
|
+
* This method NEVER logs the literal text — only key enums / byte length.
|
|
126
|
+
*/
|
|
127
|
+
injectKeys(items: MeshSendKeyItem[], opts?: {
|
|
128
|
+
allowModalOverride?: boolean;
|
|
129
|
+
}): Promise<{
|
|
130
|
+
ok: true;
|
|
131
|
+
keys: MeshSendKeyName[];
|
|
132
|
+
hasDestructive: boolean;
|
|
133
|
+
submits: boolean;
|
|
134
|
+
bytes: number;
|
|
135
|
+
} | {
|
|
136
|
+
ok: false;
|
|
137
|
+
refused: 'submit_race' | 'actionable_modal';
|
|
138
|
+
keys: MeshSendKeyName[];
|
|
139
|
+
hasDestructive: boolean;
|
|
140
|
+
}>;
|
|
77
141
|
setOnStatusChange(cb: () => void): void;
|
|
78
142
|
setOnPtyData(cb: (data: string) => void): void;
|
|
79
143
|
writeRaw(data: string): void;
|
|
@@ -126,6 +126,13 @@ export interface ISpecDriver {
|
|
|
126
126
|
col: number;
|
|
127
127
|
};
|
|
128
128
|
getScreen(): string;
|
|
129
|
+
/** Current terminal geometry (columns × rows). Optional so a non-Fsm
|
|
130
|
+
* ISpecDriver implementation (test doubles) need not provide it; the
|
|
131
|
+
* mesh_read_terminal path falls back to a 0×0 geometry when absent. */
|
|
132
|
+
getScreenSize?(): {
|
|
133
|
+
cols: number;
|
|
134
|
+
rows: number;
|
|
135
|
+
};
|
|
129
136
|
getSpecPath(): string;
|
|
130
137
|
shutdown(): void;
|
|
131
138
|
getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
|
|
@@ -251,6 +258,10 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
251
258
|
col: number;
|
|
252
259
|
};
|
|
253
260
|
getScreen(): string;
|
|
261
|
+
getScreenSize(): {
|
|
262
|
+
cols: number;
|
|
263
|
+
rows: number;
|
|
264
|
+
};
|
|
254
265
|
/** Scrollback-inclusive screen as line array — used only for modal/button
|
|
255
266
|
* content extraction so a tall prompt's off-screen anchors stay matchable.
|
|
256
267
|
* Falls back to the viewport snapshot if scrollback read is unavailable. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.554",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.554",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.554",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ChatMessage } from './types.js';
|
|
8
8
|
import type { InteractivePrompt, InteractivePromptResponse } from './providers/types/interactive-prompt.js';
|
|
9
|
+
import type { MeshSendKeyItem, MeshSendKeyName } from './cli-adapters/provider-cli-shared.js';
|
|
9
10
|
|
|
10
11
|
export interface CliAdapterStatus {
|
|
11
12
|
status?: string;
|
|
@@ -118,6 +119,34 @@ export interface CliAdapter {
|
|
|
118
119
|
cancel(): void;
|
|
119
120
|
isProcessing(): boolean;
|
|
120
121
|
isReady(): boolean;
|
|
122
|
+
// Liveness of the underlying process/PTY. Optional because not every adapter
|
|
123
|
+
// implementation exposes it (the spec-driven path historically did not); the
|
|
124
|
+
// MESH-STALL-WATCH watchdog must call it defensively (typeof guard) so a
|
|
125
|
+
// missing implementation never throws in the 5s tick.
|
|
126
|
+
isAlive?(): boolean;
|
|
127
|
+
// MESH-READ-TERMINAL (feature 2) / MESH-SEND-KEYS (feature 3). Optional
|
|
128
|
+
// because not every adapter implements the raw-terminal read / structured
|
|
129
|
+
// key-injection surface; callers (cli-provider-instance) MUST typeof-guard
|
|
130
|
+
// and return a clean unsupported result rather than throwing when absent.
|
|
131
|
+
// Both ProviderCliAdapter (PTY path) and SpecCliAdapter (native-source spec
|
|
132
|
+
// path — claude-cli / antigravity / codex-cli) implement them.
|
|
133
|
+
getTerminalScreenSnapshot?(maxBytes?: number): {
|
|
134
|
+
text: string;
|
|
135
|
+
cursor: { col: number; row: number };
|
|
136
|
+
cols: number;
|
|
137
|
+
rows: number;
|
|
138
|
+
truncated: boolean;
|
|
139
|
+
originalBytes: number;
|
|
140
|
+
returnedBytes: number;
|
|
141
|
+
hash: string;
|
|
142
|
+
};
|
|
143
|
+
injectKeys?(
|
|
144
|
+
items: MeshSendKeyItem[],
|
|
145
|
+
opts?: { allowModalOverride?: boolean },
|
|
146
|
+
): Promise<
|
|
147
|
+
| { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
|
|
148
|
+
| { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
|
|
149
|
+
>;
|
|
121
150
|
setOnStatusChange(callback: () => void): void;
|
|
122
151
|
updateRuntimeSettings?(settings: Record<string, unknown>): void;
|
|
123
152
|
setCliScripts?(scripts: Record<string, unknown>): void;
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
RefineContext,
|
|
36
36
|
RefineExecFileAsync,
|
|
37
37
|
RefineStageOutcome,
|
|
38
|
+
classifyPatchEquivalenceFailure,
|
|
38
39
|
recordMeshRefineStage,
|
|
39
40
|
resolveRefineryAutoPublishSubmoduleMainCommits,
|
|
40
41
|
runMeshRefineEffectiveDiffGate,
|
|
@@ -616,9 +617,20 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
|
|
|
616
617
|
error: submoduleHintPatchEquivalence.error,
|
|
617
618
|
actionableHint: submoduleHintPatchEquivalence.actionableHint,
|
|
618
619
|
});
|
|
620
|
+
const classification = await classifyPatchEquivalenceFailure(
|
|
621
|
+
repoRoot, baseHead, ctx.branchHead, submoduleHintPatchEquivalence,
|
|
622
|
+
{
|
|
623
|
+
targetBaseRef: baseHead,
|
|
624
|
+
autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled,
|
|
625
|
+
},
|
|
626
|
+
);
|
|
619
627
|
return { kind: 'terminal', result: {
|
|
620
628
|
success: false,
|
|
621
629
|
code: 'patch_equivalence_failed',
|
|
630
|
+
detailedReason: classification.detailedReason,
|
|
631
|
+
detailedReasonDescription: classification.detailedReasonDescription,
|
|
632
|
+
recommendedAction: classification.recommendedAction,
|
|
633
|
+
evidence: classification.evidence,
|
|
622
634
|
convergenceStatus: 'blocked_review',
|
|
623
635
|
error: 'Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.',
|
|
624
636
|
branch,
|
|
@@ -801,7 +813,7 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
801
813
|
export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
802
814
|
// DS2: node/execFileAsync are no longer needed here — the rebase moved to
|
|
803
815
|
// sync_base — and branchHead/patchEquivalence are no longer mutated in-stage.
|
|
804
|
-
const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
|
|
816
|
+
const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
|
|
805
817
|
const branchHead = ctx.branchHead;
|
|
806
818
|
const patchEquivalenceStarted = Date.now();
|
|
807
819
|
const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
@@ -826,9 +838,24 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
|
|
|
826
838
|
// branch itself has no changes (degenerate), which is NOT already-merged.
|
|
827
839
|
const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
|
|
828
840
|
if (!alreadyMergedViaOtherPath) {
|
|
841
|
+
const classification = await classifyPatchEquivalenceFailure(
|
|
842
|
+
repoRoot, baseHead, branchHead, patchEquivalence,
|
|
843
|
+
{
|
|
844
|
+
targetBaseRef: baseHead,
|
|
845
|
+
autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled,
|
|
846
|
+
},
|
|
847
|
+
);
|
|
848
|
+
recordMeshRefineStage(refineStages, 'patch_equivalence_classification', 'failed', patchEquivalenceStarted, {
|
|
849
|
+
detailedReason: classification.detailedReason,
|
|
850
|
+
recommendedAction: classification.recommendedAction,
|
|
851
|
+
});
|
|
829
852
|
return { kind: 'terminal', result: {
|
|
830
853
|
success: false,
|
|
831
854
|
code: 'patch_equivalence_failed',
|
|
855
|
+
detailedReason: classification.detailedReason,
|
|
856
|
+
detailedReasonDescription: classification.detailedReasonDescription,
|
|
857
|
+
recommendedAction: classification.recommendedAction,
|
|
858
|
+
evidence: classification.evidence,
|
|
832
859
|
convergenceStatus: 'blocked_review',
|
|
833
860
|
error: 'Refinery patch-equivalence preflight failed; merge/refine was not attempted.',
|
|
834
861
|
branch,
|
|
@@ -2278,6 +2305,15 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
2278
2305
|
};
|
|
2279
2306
|
if (typeof result.error === 'string') ctx.error = result.error;
|
|
2280
2307
|
if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
|
|
2308
|
+
// Detailed patch-equivalence sub-cause classification (base_divergence,
|
|
2309
|
+
// submodule_unreachable, actual_patch_diff, trivial_ff_misjudgment,
|
|
2310
|
+
// already_converged, unclassified) + recommended action + evidence.
|
|
2311
|
+
// Promoted onto blockerContext so coordinators reading task_failed ledger
|
|
2312
|
+
// entries see the cause without parsing the free-form error string.
|
|
2313
|
+
if (typeof result.detailedReason === 'string') ctx.detailedReason = result.detailedReason;
|
|
2314
|
+
if (typeof result.detailedReasonDescription === 'string') ctx.detailedReasonDescription = result.detailedReasonDescription;
|
|
2315
|
+
if (typeof result.recommendedAction === 'string') ctx.recommendedAction = result.recommendedAction;
|
|
2316
|
+
if (result.evidence && typeof result.evidence === 'object') ctx.evidence = result.evidence;
|
|
2281
2317
|
// Patch equivalence details
|
|
2282
2318
|
if (stage === 'patch_equivalence' && result.patchEquivalence) {
|
|
2283
2319
|
const pe = result.patchEquivalence as Record<string, unknown>;
|
|
@@ -2287,6 +2323,9 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
2287
2323
|
status: pe.status,
|
|
2288
2324
|
actionableHint: pe.actionableHint,
|
|
2289
2325
|
error: pe.error,
|
|
2326
|
+
...(typeof result.detailedReason === 'string' ? { detailedReason: result.detailedReason } : {}),
|
|
2327
|
+
...(typeof result.recommendedAction === 'string' ? { recommendedAction: result.recommendedAction } : {}),
|
|
2328
|
+
...(result.evidence && typeof result.evidence === 'object' ? { evidence: result.evidence } : {}),
|
|
2290
2329
|
};
|
|
2291
2330
|
}
|
|
2292
2331
|
// Submodule reachability details
|
|
@@ -556,6 +556,262 @@ export async function runMeshRefinePatchEquivalenceGate(
|
|
|
556
556
|
}
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Machine-readable sub-classification of a `patch_equivalence_failed` (and the
|
|
561
|
+
* related submodule-gitlink preflight blocks). The opaque top-level
|
|
562
|
+
* `patch_equivalence_failed` code is preserved for backward compatibility; this
|
|
563
|
+
* detailed reason is added ALONGSIDE it so coordinators no longer have to guess
|
|
564
|
+
* WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
|
|
565
|
+
* the real cause was a diverged base + an unreachable submodule gitlink artifact,
|
|
566
|
+
* not a real patch conflict, but Refinery only returned the opaque code and the
|
|
567
|
+
* coordinator mis-attributed it to a stale daemon version).
|
|
568
|
+
*/
|
|
569
|
+
export type MeshRefinePatchEquivalenceDetailedReasonCode =
|
|
570
|
+
/** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
|
|
571
|
+
| 'base_divergence'
|
|
572
|
+
/** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
|
|
573
|
+
| 'submodule_unreachable'
|
|
574
|
+
/** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
|
|
575
|
+
| 'actual_patch_diff'
|
|
576
|
+
/** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
|
|
577
|
+
| 'trivial_ff_misjudgment'
|
|
578
|
+
/** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
|
|
579
|
+
| 'already_converged'
|
|
580
|
+
/** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
|
|
581
|
+
| 'unclassified';
|
|
582
|
+
|
|
583
|
+
export type MeshRefinePatchEquivalenceFailureClassification = {
|
|
584
|
+
detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
|
|
585
|
+
/** Human-readable one-line description of the sub-cause. */
|
|
586
|
+
detailedReasonDescription: string;
|
|
587
|
+
/** Suggested next action for the coordinator/owner (free-form, actionable). */
|
|
588
|
+
recommendedAction: string;
|
|
589
|
+
/** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
|
|
590
|
+
evidence: {
|
|
591
|
+
baseHead?: string;
|
|
592
|
+
branchHead?: string;
|
|
593
|
+
mergeBase?: string;
|
|
594
|
+
/** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
|
|
595
|
+
behind?: number;
|
|
596
|
+
/** How many commits the branch is ahead of the merge-base. */
|
|
597
|
+
ahead?: number;
|
|
598
|
+
/** True when HEAD is NOT a descendant of the target base (diverged). */
|
|
599
|
+
baseDiverged?: boolean;
|
|
600
|
+
expectedPatchId?: string;
|
|
601
|
+
actualPatchId?: string;
|
|
602
|
+
patchIdEqual?: boolean;
|
|
603
|
+
/** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
|
|
604
|
+
diffStat?: string;
|
|
605
|
+
/** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
|
|
606
|
+
submoduleGitlinks?: Array<{
|
|
607
|
+
path: string;
|
|
608
|
+
baseCommit?: string;
|
|
609
|
+
branchCommit?: string;
|
|
610
|
+
/** True when branchCommit descends baseCommit (a strict fast-forward advance). */
|
|
611
|
+
fastForward?: boolean;
|
|
612
|
+
/** True when branchCommit is reachable from the submodule's local origin/main. */
|
|
613
|
+
reachableFromOriginMain?: boolean;
|
|
614
|
+
}>;
|
|
615
|
+
/** Effective auto-publish-submodule-main-commits policy value at classification time. */
|
|
616
|
+
autoPublishSubmoduleMainCommits?: boolean;
|
|
617
|
+
/** Set when the classifier itself errored (detailedReason === 'unclassified'). */
|
|
618
|
+
classifierError?: string;
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Classify WHY a patch-equivalence preflight blocked, turning the opaque
|
|
624
|
+
* `patch_equivalence_failed` code into a machine-readable {@link
|
|
625
|
+
* MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
|
|
626
|
+
* structured evidence. Read-only: runs only `git` inspection commands (rev-list,
|
|
627
|
+
* merge-base, diff --stat, submodule reachability probes) against the already-set
|
|
628
|
+
* worktree — it never mutates the repo.
|
|
629
|
+
*
|
|
630
|
+
* Priority of classification (first match wins):
|
|
631
|
+
* 1. already_converged — ahead 0 & behind 0 & no residual diff
|
|
632
|
+
* 2. submodule_unreachable — a changed gitlink commit is not reachable from the
|
|
633
|
+
* submodule's origin/main (publish needed)
|
|
634
|
+
* 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
|
|
635
|
+
* patch-ids match — blocked only by a ff gitlink
|
|
636
|
+
* 4. base_divergence — HEAD is not a descendant of the target base
|
|
637
|
+
* 5. actual_patch_diff — genuine content divergence (the residual case)
|
|
638
|
+
*
|
|
639
|
+
* `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
|
|
640
|
+
* or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
|
|
641
|
+
* the submodule_unreachable recommendation can name the current policy value.
|
|
642
|
+
*/
|
|
643
|
+
export async function classifyPatchEquivalenceFailure(
|
|
644
|
+
repoRoot: string,
|
|
645
|
+
baseHead: string,
|
|
646
|
+
branchHead: string,
|
|
647
|
+
summary: MeshRefinePatchEquivalenceSummary,
|
|
648
|
+
options: { targetBaseRef?: string; autoPublishSubmoduleMainCommits?: boolean } = {},
|
|
649
|
+
): Promise<MeshRefinePatchEquivalenceFailureClassification> {
|
|
650
|
+
const targetBaseRef = options.targetBaseRef || baseHead;
|
|
651
|
+
const autoPublish = options.autoPublishSubmoduleMainCommits;
|
|
652
|
+
const evidence: MeshRefinePatchEquivalenceFailureClassification['evidence'] = {
|
|
653
|
+
baseHead,
|
|
654
|
+
branchHead,
|
|
655
|
+
mergeBase: summary.mergeBase,
|
|
656
|
+
expectedPatchId: summary.expectedPatchId,
|
|
657
|
+
actualPatchId: summary.actualPatchId,
|
|
658
|
+
patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
|
|
659
|
+
...(autoPublish !== undefined ? { autoPublishSubmoduleMainCommits: autoPublish } : {}),
|
|
660
|
+
};
|
|
661
|
+
try {
|
|
662
|
+
const git = (args: string[]): string => execFileSync(GIT, args, {
|
|
663
|
+
cwd: repoRoot,
|
|
664
|
+
encoding: 'utf8',
|
|
665
|
+
maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
|
|
666
|
+
windowsHide: true,
|
|
667
|
+
});
|
|
668
|
+
const gitOk = (args: string[]): boolean => {
|
|
669
|
+
try { git(args); return true; } catch { return false; }
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// ahead/behind of branch vs the target base ref. left = base-only (behind),
|
|
673
|
+
// right = branch-only (ahead).
|
|
674
|
+
let ahead = 0;
|
|
675
|
+
let behind = 0;
|
|
676
|
+
try {
|
|
677
|
+
const out = git(['rev-list', '--left-right', '--count', `${targetBaseRef}...${branchHead}`]).trim();
|
|
678
|
+
const [left, right] = out.split(/\s+/).map(n => Number.parseInt(n, 10));
|
|
679
|
+
behind = Number.isFinite(left) ? left : 0;
|
|
680
|
+
ahead = Number.isFinite(right) ? right : 0;
|
|
681
|
+
} catch { /* keep zeros */ }
|
|
682
|
+
evidence.ahead = ahead;
|
|
683
|
+
evidence.behind = behind;
|
|
684
|
+
// HEAD (branchHead) diverged from the target base = base is NOT an ancestor
|
|
685
|
+
// of the branch. behind>0 with the base ref not reachable from HEAD.
|
|
686
|
+
const baseIsAncestor = gitOk(['merge-base', '--is-ancestor', targetBaseRef, branchHead]);
|
|
687
|
+
evidence.baseDiverged = !baseIsAncestor;
|
|
688
|
+
|
|
689
|
+
// Residual/actual diff stat (best-effort): what the merge would still introduce.
|
|
690
|
+
let diffStat = '';
|
|
691
|
+
try {
|
|
692
|
+
if (summary.mergedTree) {
|
|
693
|
+
diffStat = git(['diff', '--stat', baseHead, summary.mergedTree]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
694
|
+
} else {
|
|
695
|
+
diffStat = git(['diff', '--stat', baseHead, branchHead]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
696
|
+
}
|
|
697
|
+
} catch { /* diff stat is best-effort */ }
|
|
698
|
+
if (diffStat) evidence.diffStat = diffStat;
|
|
699
|
+
|
|
700
|
+
// Changed gitlink reachability against each submodule's local origin/main.
|
|
701
|
+
const submoduleGitlinks: NonNullable<MeshRefinePatchEquivalenceFailureClassification['evidence']['submoduleGitlinks']> = [];
|
|
702
|
+
try {
|
|
703
|
+
const nameStatus = git(['diff', '--name-only', '--diff-filter=d', baseHead, branchHead]).trim();
|
|
704
|
+
const changedPaths = nameStatus ? nameStatus.split('\n').map(p => p.trim()).filter(Boolean) : [];
|
|
705
|
+
for (const p of changedPaths) {
|
|
706
|
+
// Only submodule (gitlink, mode 160000) entries.
|
|
707
|
+
let baseCommit: string | undefined;
|
|
708
|
+
let branchCommit: string | undefined;
|
|
709
|
+
try {
|
|
710
|
+
const baseLs = git(['ls-tree', baseHead, '--', p]).trim();
|
|
711
|
+
const branchLs = git(['ls-tree', branchHead, '--', p]).trim();
|
|
712
|
+
const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
|
|
713
|
+
if (!isGitlink) continue;
|
|
714
|
+
baseCommit = baseLs.split(/\s+/)[2];
|
|
715
|
+
branchCommit = branchLs.split(/\s+/)[2];
|
|
716
|
+
} catch { continue; }
|
|
717
|
+
const submoduleRepo = pathJoin(repoRoot, p);
|
|
718
|
+
let fastForward: boolean | undefined;
|
|
719
|
+
let reachableFromOriginMain: boolean | undefined;
|
|
720
|
+
if (branchCommit) {
|
|
721
|
+
if (baseCommit) {
|
|
722
|
+
fastForward = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', baseCommit, branchCommit]);
|
|
723
|
+
}
|
|
724
|
+
reachableFromOriginMain = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', branchCommit, 'refs/remotes/origin/main']);
|
|
725
|
+
}
|
|
726
|
+
submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
|
|
727
|
+
}
|
|
728
|
+
} catch { /* submodule inspection is best-effort */ }
|
|
729
|
+
if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
|
|
730
|
+
|
|
731
|
+
// Existing gate signal: the merge-tree trivial-ff evaluation, if the gate
|
|
732
|
+
// captured it (a genuine non-trivial submodule conflict lands here too).
|
|
733
|
+
const gitlinkFf = summary.gitlinkTrivialFastForward;
|
|
734
|
+
|
|
735
|
+
// ── Classification (first match wins) ────────────────────────────────
|
|
736
|
+
const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === '');
|
|
737
|
+
|
|
738
|
+
// 1. already_converged: nothing ahead, nothing behind, no residual diff.
|
|
739
|
+
if (ahead === 0 && behind === 0 && noResidualDiff) {
|
|
740
|
+
return {
|
|
741
|
+
detailedReason: 'already_converged',
|
|
742
|
+
detailedReasonDescription: 'Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.',
|
|
743
|
+
recommendedAction: 'Treat as already converged — no merge needed. Verify with `git range-diff` / patch-id, then mark the branch merged (or clean up the worktree).',
|
|
744
|
+
evidence,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 2. submodule_unreachable: a changed gitlink is not reachable from the
|
|
749
|
+
// submodule's origin/main. This is the publish-needed artifact.
|
|
750
|
+
const unreachable = submoduleGitlinks.filter(g => g.reachableFromOriginMain === false);
|
|
751
|
+
if (unreachable.length > 0) {
|
|
752
|
+
const paths = unreachable.map(g => g.path).join(', ');
|
|
753
|
+
return {
|
|
754
|
+
detailedReason: 'submodule_unreachable',
|
|
755
|
+
detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
|
|
756
|
+
recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === undefined ? 'unknown' : autoPublish}).`,
|
|
757
|
+
evidence,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 3. trivial_ff_misjudgment: HEAD descends the target base AND the non-gitlink
|
|
762
|
+
// patch-ids are equal, so the ONLY thing blocking is a fast-forward gitlink
|
|
763
|
+
// that merge-tree refused. (Either the gate flagged an unresolved gitlink
|
|
764
|
+
// ff, or every changed gitlink is a proven ff.)
|
|
765
|
+
const changedGitlinks = submoduleGitlinks.length > 0;
|
|
766
|
+
const allGitlinksFf = changedGitlinks && submoduleGitlinks.every(g => g.fastForward === true);
|
|
767
|
+
const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some(g => g.fastForward);
|
|
768
|
+
if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
|
|
769
|
+
return {
|
|
770
|
+
detailedReason: 'trivial_ff_misjudgment',
|
|
771
|
+
detailedReasonDescription: 'HEAD descends the target base and the patch content matches; the block is a submodule gitlink trivial fast-forward that merge-tree refused, not a real divergence.',
|
|
772
|
+
recommendedAction: 'Converge via the strict fast-forward-only bypass (verify HEAD descends origin/main and patch-id equality, then merge --ff-only) instead of the refine gate.',
|
|
773
|
+
evidence,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 4. base_divergence: HEAD is not a descendant of the target base.
|
|
778
|
+
if (!baseIsAncestor) {
|
|
779
|
+
return {
|
|
780
|
+
detailedReason: 'base_divergence',
|
|
781
|
+
detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
|
|
782
|
+
recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
|
|
783
|
+
evidence,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 5. actual_patch_diff: genuine non-equivalent content.
|
|
788
|
+
return {
|
|
789
|
+
detailedReason: 'actual_patch_diff',
|
|
790
|
+
detailedReasonDescription: 'The merge introduces content not equivalent to the branch\'s cumulative patch (expected tree vs actual merge diff differ).',
|
|
791
|
+
recommendedAction: 'Manual review required — inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.',
|
|
792
|
+
evidence,
|
|
793
|
+
};
|
|
794
|
+
} catch (e: any) {
|
|
795
|
+
evidence.classifierError = e?.message || String(e);
|
|
796
|
+
return {
|
|
797
|
+
detailedReason: 'unclassified',
|
|
798
|
+
detailedReasonDescription: 'Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.',
|
|
799
|
+
recommendedAction: 'Inspect the refineStages and patchEquivalence summary manually to determine the cause.',
|
|
800
|
+
evidence,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Small helper: run a git command in `cwd` and return whether it exited 0. */
|
|
806
|
+
function execGitOk(cwd: string, args: string[]): boolean {
|
|
807
|
+
try {
|
|
808
|
+
execFileSync(GIT, args, { cwd, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
|
|
809
|
+
return true;
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
559
815
|
export type MeshWorktreePatchContainmentSummary = {
|
|
560
816
|
/** True only when merging worktreeHead into ref introduces no new patch. */
|
|
561
817
|
contained: boolean;
|