@adhdev/daemon-core 0.9.82-rc.486 → 0.9.82-rc.488
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/router-refine.d.ts +14 -0
- package/dist/git/git-status.d.ts +23 -0
- package/dist/index.js +232 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +232 -33
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-forwarding.d.ts +9 -0
- package/dist/mesh/mesh-refine-gates.d.ts +29 -0
- package/dist/mesh/mesh-work-queue.d.ts +11 -0
- package/dist/providers/cli-provider-instance.d.ts +1 -0
- package/dist/providers/provider-instance-manager.d.ts +1 -0
- package/dist/providers/provider-instance.d.ts +1 -0
- package/package.json +3 -3
- package/src/commands/cli-manager.ts +4 -0
- package/src/commands/router-refine.ts +87 -4
- package/src/git/git-status.ts +84 -29
- package/src/mesh/mesh-event-forwarding.ts +92 -0
- package/src/mesh/mesh-queue-assignment.ts +6 -0
- package/src/mesh/mesh-refine-gates.ts +113 -7
- package/src/mesh/mesh-runtime-store.ts +5 -0
- package/src/mesh/mesh-work-queue.ts +18 -0
- package/src/providers/cli-provider-instance.ts +17 -6
- package/src/providers/provider-instance-manager.ts +1 -1
- package/src/providers/provider-instance.ts +1 -1
|
@@ -13,12 +13,21 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
|
|
|
13
13
|
forwarded: number;
|
|
14
14
|
suppressed: boolean;
|
|
15
15
|
autoApprovingWorkerApproval: boolean;
|
|
16
|
+
staleDispatchRejected?: undefined;
|
|
17
|
+
error?: undefined;
|
|
18
|
+
} | {
|
|
19
|
+
success: boolean;
|
|
20
|
+
forwarded: number;
|
|
21
|
+
suppressed: boolean;
|
|
22
|
+
staleDispatchRejected: boolean;
|
|
23
|
+
autoApprovingWorkerApproval?: undefined;
|
|
16
24
|
error?: undefined;
|
|
17
25
|
} | {
|
|
18
26
|
success: boolean;
|
|
19
27
|
forwarded: number;
|
|
20
28
|
suppressed?: undefined;
|
|
21
29
|
autoApprovingWorkerApproval?: undefined;
|
|
30
|
+
staleDispatchRejected?: undefined;
|
|
22
31
|
error?: undefined;
|
|
23
32
|
} | {
|
|
24
33
|
success: boolean;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* keep working. `CommandRouterResult` is imported type-only from router.ts
|
|
12
12
|
* (erased at compile time — no runtime import cycle).
|
|
13
13
|
*/
|
|
14
|
+
import type { ChangedPackageClassification } from '../git/git-status.js';
|
|
14
15
|
import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
|
|
15
16
|
import type { CommandRouterResult } from '../commands/router.js';
|
|
16
17
|
export type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
|
|
@@ -53,6 +54,19 @@ type MeshRefineValidationSummary = {
|
|
|
53
54
|
};
|
|
54
55
|
/** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
|
|
55
56
|
deprecationWarnings?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Coarse daemon-vs-web change-impact used to scope the validation command set.
|
|
59
|
+
* When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
|
|
60
|
+
* `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
|
|
61
|
+
* rather than executed; web + typecheck commands always run. Absent when no
|
|
62
|
+
* change-impact was threaded in (legacy: full command set runs).
|
|
63
|
+
*/
|
|
64
|
+
changeImpact?: {
|
|
65
|
+
isDaemonAffecting: boolean;
|
|
66
|
+
affectedPackages: string[];
|
|
67
|
+
/** displayCommands skipped because the daemon scope is unaffected. */
|
|
68
|
+
skippedDaemonCommands?: string[];
|
|
69
|
+
};
|
|
56
70
|
};
|
|
57
71
|
type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
|
|
58
72
|
type MeshRefinePatchEquivalenceSummary = {
|
|
@@ -277,6 +291,13 @@ export interface RefineContext {
|
|
|
277
291
|
baseBranch: string;
|
|
278
292
|
baseHead: string;
|
|
279
293
|
branchHead: string;
|
|
294
|
+
/**
|
|
295
|
+
* Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
|
|
296
|
+
* resolve_refs stage and threaded into the validation gate to scope its command
|
|
297
|
+
* set. `undefined` means "could not classify" → the gate fails open and runs ALL
|
|
298
|
+
* commands (never skip on uncertainty).
|
|
299
|
+
*/
|
|
300
|
+
changeImpact?: ChangedPackageClassification;
|
|
280
301
|
validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
|
|
281
302
|
patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
|
|
282
303
|
submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
|
|
@@ -424,5 +445,13 @@ export declare function runMeshRefineValidationGate(mesh: any, workspace: string
|
|
|
424
445
|
persistedBootstrapState?: WorktreeBootstrapState | null;
|
|
425
446
|
/** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
|
|
426
447
|
onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
|
|
448
|
+
/**
|
|
449
|
+
* Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
|
|
450
|
+
* over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
|
|
451
|
+
* daemon-scoped validation commands are skipped (web + typecheck still run).
|
|
452
|
+
* When omitted or `isDaemonAffecting === true`, the full command set runs —
|
|
453
|
+
* fail-open to full validation on any uncertainty.
|
|
454
|
+
*/
|
|
455
|
+
changeImpact?: ChangedPackageClassification;
|
|
427
456
|
}): Promise<MeshRefineValidationSummary>;
|
|
428
457
|
export {};
|
|
@@ -179,6 +179,17 @@ export interface MeshWorkQueueEntry {
|
|
|
179
179
|
};
|
|
180
180
|
/** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
|
|
181
181
|
dispatchTimestamp?: string;
|
|
182
|
+
/**
|
|
183
|
+
* REDRIVE-DUP: monotonic per-task dispatch nonce. Bumped on every (re)dispatch of
|
|
184
|
+
* this task (assignQueueTask) AND on every reclaim (reclaimStrandedAssignedTask), and
|
|
185
|
+
* carried to the worker in meshContext.dispatchNonce. The worker echoes it back on
|
|
186
|
+
* agent:generating_started (metadataEvent.dispatchNonce). When a delivered-not-consumed
|
|
187
|
+
* task is reclaimed and re-dispatched to a different node, the ORIGINAL inject to the
|
|
188
|
+
* first node still carries the now-stale nonce; the coordinator rejects that node's
|
|
189
|
+
* generating_started ack (and stops it) so the SAME taskId is never executed twice.
|
|
190
|
+
* Absent on legacy rows → the coordinator skips the stale-nonce guard (backward safe).
|
|
191
|
+
*/
|
|
192
|
+
dispatchNonce?: number;
|
|
182
193
|
/**
|
|
183
194
|
* (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
|
|
184
195
|
* worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
|
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.488",
|
|
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.488",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.488",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -1725,6 +1725,10 @@ export class DaemonCliManager {
|
|
|
1725
1725
|
meshId: meshContext.meshId,
|
|
1726
1726
|
...(typeof meshContext.nodeId === 'string' && meshContext.nodeId ? { nodeId: meshContext.nodeId } : {}),
|
|
1727
1727
|
...(typeof meshContext.taskId === 'string' && meshContext.taskId ? { taskId: meshContext.taskId } : {}),
|
|
1728
|
+
// REDRIVE-DUP: carry the dispatch nonce onto the worker session so
|
|
1729
|
+
// its generating_started event echoes it back for the coordinator's
|
|
1730
|
+
// stale-nonce guard.
|
|
1731
|
+
...(typeof meshContext.dispatchNonce === 'number' ? { dispatchNonce: meshContext.dispatchNonce } : {}),
|
|
1728
1732
|
...(typeof meshContext.coordinatorDaemonId === 'string' && meshContext.coordinatorDaemonId ? { coordinatorDaemonId: meshContext.coordinatorDaemonId } : {}),
|
|
1729
1733
|
});
|
|
1730
1734
|
} catch { /* best-effort — stamping is a routing aid, not a hard requirement */ }
|
|
@@ -17,6 +17,8 @@ import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mes
|
|
|
17
17
|
import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
|
|
18
18
|
import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
|
|
19
19
|
import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
|
|
20
|
+
import { classifyChangedPackages } from '../git/git-status.js';
|
|
21
|
+
import type { ChangedPackageClassification } from '../git/git-status.js';
|
|
20
22
|
import { readStringValue } from '../mesh/mesh-node-identity.js';
|
|
21
23
|
import {
|
|
22
24
|
alignRefinerySubmodulesAfterMerge,
|
|
@@ -80,7 +82,65 @@ export function buildRefineJobHandle(self: DaemonCommandRouter, args: {
|
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Slim the terminal-stage refine result down to the fields a coordinator needs to
|
|
87
|
+
* decide next-step, dropping the heavy per-command / per-entry detail.
|
|
88
|
+
*
|
|
89
|
+
* The full `CommandRouterResult` (with `validationSummary.commandsRun[]` carrying
|
|
90
|
+
* per-command stdout/stderr, `rejectedCommands`, `suggestions`, `suggestedConfig`,
|
|
91
|
+
* the full `patchEquivalence`, and `submoduleReachability.entries[]`/`.unreachable[]`)
|
|
92
|
+
* routinely exceeds 70KB and overflows the coordinator token limit when it rides on a
|
|
93
|
+
* `mesh_wait_events` payload. The full detail is still persisted verbatim to the ledger
|
|
94
|
+
* (`appendRefineJobLedger`) and `terminalRefineJobs`, so slimming only the EVENT loses
|
|
95
|
+
* nothing — the coordinator can pull the full record on demand via
|
|
96
|
+
* `evidence.ledgerCommand` / `taskHistoryKind`.
|
|
97
|
+
*/
|
|
98
|
+
export function slimRefineEventResult(result: Record<string, unknown>): Record<string, unknown> {
|
|
99
|
+
const slim: Record<string, unknown> = {};
|
|
100
|
+
// Top-level scalars the coordinator branches on.
|
|
101
|
+
for (const key of [
|
|
102
|
+
'success', 'code', 'error', 'convergenceStatus', 'blockedReason',
|
|
103
|
+
'branch', 'into', 'terminalKind', 'nextStep', 'finalBranchConvergenceState',
|
|
104
|
+
] as const) {
|
|
105
|
+
if (result[key] !== undefined) slim[key] = result[key];
|
|
106
|
+
}
|
|
107
|
+
// Mapped subset of the unreachable-submodule commits (path + autoPublishAllowed),
|
|
108
|
+
// not the full commit records.
|
|
109
|
+
if (Array.isArray(result.unreachableSubmoduleCommits)) {
|
|
110
|
+
slim.unreachableSubmoduleCommits = (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>)
|
|
111
|
+
.map(e => ({ path: e?.path, autoPublishAllowed: e?.autoPublishAllowed }));
|
|
112
|
+
}
|
|
113
|
+
// Reduced validation summary — status + failure classification + config source
|
|
114
|
+
// + a count of commands run (drop the full commandsRun/rejectedCommands/
|
|
115
|
+
// suggestions/suggestedConfig detail).
|
|
116
|
+
if (result.validationSummary && typeof result.validationSummary === 'object') {
|
|
117
|
+
const vs = result.validationSummary as Record<string, unknown>;
|
|
118
|
+
slim.validationSummary = {
|
|
119
|
+
status: vs.status,
|
|
120
|
+
failureCode: vs.failureCode,
|
|
121
|
+
configSource: vs.configSource,
|
|
122
|
+
configSourceType: vs.configSourceType,
|
|
123
|
+
commandsRunCount: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Reduce patch-equivalence to just its verdict.
|
|
127
|
+
if (result.patchEquivalence && typeof result.patchEquivalence === 'object') {
|
|
128
|
+
const pe = result.patchEquivalence as Record<string, unknown>;
|
|
129
|
+
slim.patchEquivalence = { status: pe.status, equivalent: pe.equivalent };
|
|
130
|
+
}
|
|
131
|
+
// Reduce submodule reachability to counts; drop the full entries/unreachable arrays.
|
|
132
|
+
if (result.submoduleReachability && typeof result.submoduleReachability === 'object') {
|
|
133
|
+
const sr = result.submoduleReachability as Record<string, unknown>;
|
|
134
|
+
slim.submoduleReachability = {
|
|
135
|
+
checked: Array.isArray(sr.entries) ? sr.entries.length : undefined,
|
|
136
|
+
unreachable: Array.isArray(sr.unreachable) ? sr.unreachable.length : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return slim;
|
|
140
|
+
}
|
|
141
|
+
|
|
83
142
|
export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:accepted' | 'refine:completed' | 'refine:failed', handle: MeshRefineJobHandle, result?: Record<string, unknown>): void {
|
|
143
|
+
const slimResult = result ? slimRefineEventResult(result) : undefined;
|
|
84
144
|
const metadataEvent = {
|
|
85
145
|
source: 'refine_mesh_node_async_job',
|
|
86
146
|
jobId: handle.jobId,
|
|
@@ -93,7 +153,7 @@ export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:ac
|
|
|
93
153
|
startedAt: handle.startedAt,
|
|
94
154
|
completedAt: handle.completedAt,
|
|
95
155
|
retryOfJobId: handle.retryOfJobId,
|
|
96
|
-
...(
|
|
156
|
+
...(slimResult ? { result: slimResult } : {}),
|
|
97
157
|
};
|
|
98
158
|
const eventPayload = {
|
|
99
159
|
event,
|
|
@@ -120,7 +180,7 @@ export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:ac
|
|
|
120
180
|
startedAt: handle.startedAt,
|
|
121
181
|
completedAt: handle.completedAt,
|
|
122
182
|
retryOfJobId: handle.retryOfJobId,
|
|
123
|
-
...(
|
|
183
|
+
...(slimResult ? { result: slimResult } : {}),
|
|
124
184
|
},
|
|
125
185
|
);
|
|
126
186
|
if (forwarded?.success === true) return;
|
|
@@ -312,7 +372,24 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
|
|
|
312
372
|
const { stdout: branchHeadStdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
|
|
313
373
|
const baseHead = baseHeadRaw;
|
|
314
374
|
const branchHead = branchHeadStdout.trim();
|
|
315
|
-
|
|
375
|
+
|
|
376
|
+
// Coarse daemon-vs-web change-impact for baseHead..branchHead, computed
|
|
377
|
+
// against the worktree so the same policy (.adhdev/change-impact.*) as the
|
|
378
|
+
// stale-build detector applies. Threaded onto ctx so the validation gate
|
|
379
|
+
// can scope its command set: a web-only branch skips daemon-scoped commands.
|
|
380
|
+
// FAIL-OPEN: any classification error leaves changeImpact undefined → the
|
|
381
|
+
// gate runs the full command set (never skip on uncertainty).
|
|
382
|
+
let changeImpact: ChangedPackageClassification | undefined;
|
|
383
|
+
try {
|
|
384
|
+
changeImpact = await classifyChangedPackages(node.workspace, baseHead, branchHead);
|
|
385
|
+
} catch {
|
|
386
|
+
changeImpact = undefined;
|
|
387
|
+
}
|
|
388
|
+
recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, {
|
|
389
|
+
branch, baseBranch, baseHead, branchHead,
|
|
390
|
+
...(changeImpact ? { changeImpact } : {}),
|
|
391
|
+
...(fetchWarning ? { fetchWarning } : {}),
|
|
392
|
+
});
|
|
316
393
|
|
|
317
394
|
return {
|
|
318
395
|
kind: 'continue',
|
|
@@ -330,6 +407,7 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
|
|
|
330
407
|
baseBranch,
|
|
331
408
|
baseHead,
|
|
332
409
|
branchHead,
|
|
410
|
+
changeImpact,
|
|
333
411
|
validationSummary: undefined as any,
|
|
334
412
|
patchEquivalence: undefined as any,
|
|
335
413
|
submoduleReachability: undefined as any,
|
|
@@ -346,6 +424,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
346
424
|
const { mesh, node, branch, baseBranch, refineStages } = ctx;
|
|
347
425
|
const validationStarted = Date.now();
|
|
348
426
|
const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace, {
|
|
427
|
+
// (a) Scope the validation command set by coarse change-impact (resolved
|
|
428
|
+
// in resolve_refs). Undefined → gate runs the full command set (fail-open).
|
|
429
|
+
changeImpact: ctx.changeImpact,
|
|
349
430
|
// M2-2: consume the node's persisted bootstrap state; persist re-runs.
|
|
350
431
|
persistedBootstrapState: (node as any).worktreeBootstrap as WorktreeBootstrapState | undefined,
|
|
351
432
|
onBootstrapStateChange: (state) => {
|
|
@@ -369,7 +450,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
369
450
|
: undefined;
|
|
370
451
|
const buildValidationFailedError = (): string => {
|
|
371
452
|
const base = validationSummary.failureCode === 'missing_dependencies'
|
|
372
|
-
? 'Refinery validation dependencies are missing; merge/refine was not attempted.
|
|
453
|
+
? 'Refinery validation dependencies are missing for a change-affected package; merge/refine was not attempted. '
|
|
454
|
+
+ 'To make this self-service, either (1) configure .adhdev/worktree_bootstrap.json (or validation.bootstrapCommands in .adhdev/refine.json) so Refinery installs deps before validation, '
|
|
455
|
+
+ 'or (2) converge the branch via the documented manual fast-forward-only bypass (rebase onto the fetched base, verify strict ancestry, then push ff-only) instead of the refine gate.'
|
|
373
456
|
: validationSummary.failureCode === 'dependency_bootstrap_failed'
|
|
374
457
|
? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
|
|
375
458
|
: validationSummary.failureCode === 'spawn_resolution_failed'
|
package/src/git/git-status.ts
CHANGED
|
@@ -438,6 +438,54 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
|
|
|
438
438
|
return false;
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
/** Coarse change-impact verdict produced from a changed-file list. */
|
|
442
|
+
export interface ChangedPackageClassification {
|
|
443
|
+
isDaemonAffecting: boolean;
|
|
444
|
+
affectedPackages: string[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Pure bucketer: classify an already-collected changed-file list into the coarse
|
|
449
|
+
* daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
|
|
450
|
+
* (buildCommit..HEAD) and classifyChangedPackages (arbitrary ref range) so the
|
|
451
|
+
* daemon/web boundary logic lives in exactly one place. An empty list stays
|
|
452
|
+
* conservative (daemon-affecting) so an actionable warning is never suppressed.
|
|
453
|
+
*/
|
|
454
|
+
function classifyChangedFileList(
|
|
455
|
+
files: string[],
|
|
456
|
+
policy: ResolvedChangeImpactPolicy,
|
|
457
|
+
): ChangedPackageClassification {
|
|
458
|
+
if (files.length === 0) {
|
|
459
|
+
// No file diff (e.g. only merge metadata) — nothing actionable, but stay
|
|
460
|
+
// conservative and treat as daemon-affecting so we don't suppress a real warning.
|
|
461
|
+
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
462
|
+
}
|
|
463
|
+
const pkgs = new Set<string>();
|
|
464
|
+
// A non-package path that is NOT a recognized benign root file (marker/doc).
|
|
465
|
+
// Only these force daemon-affecting; benign markers/docs are ignored so a
|
|
466
|
+
// gitlink-moving root commit over a marker-only oss commit no longer over-warns.
|
|
467
|
+
let sawRuntimeAmbiguousNonPackage = false;
|
|
468
|
+
for (const file of files) {
|
|
469
|
+
const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
|
|
470
|
+
if (!match) {
|
|
471
|
+
if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
pkgs.add(match[1]);
|
|
475
|
+
}
|
|
476
|
+
const affectedPackages = [...pkgs].sort();
|
|
477
|
+
// Daemon-affecting if: any runtime-ambiguous non-package file changed, any
|
|
478
|
+
// unknown package changed, or any explicit daemon-runtime package changed.
|
|
479
|
+
// The daemon is unaffected only when every changed file is either a known
|
|
480
|
+
// web-only package or a recognized benign root file (and at least one such
|
|
481
|
+
// file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
|
|
482
|
+
// therefore stay daemon-affecting (fail-safe default preserved).
|
|
483
|
+
const allBenign =
|
|
484
|
+
!sawRuntimeAmbiguousNonPackage &&
|
|
485
|
+
affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
|
|
486
|
+
return { isDaemonAffecting: !allBenign, affectedPackages };
|
|
487
|
+
}
|
|
488
|
+
|
|
441
489
|
/**
|
|
442
490
|
* Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
|
|
443
491
|
* package, per the resolved policy. Returns isDaemonAffecting:true conservatively
|
|
@@ -449,47 +497,54 @@ async function classifyDaemonBuildChange(
|
|
|
449
497
|
buildCommit: string,
|
|
450
498
|
options: GitStatusOptions,
|
|
451
499
|
policy: ResolvedChangeImpactPolicy,
|
|
452
|
-
): Promise<
|
|
500
|
+
): Promise<ChangedPackageClassification> {
|
|
453
501
|
try {
|
|
454
502
|
const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
|
|
455
503
|
const files = diff.stdout
|
|
456
504
|
.split('\n')
|
|
457
505
|
.map((line) => line.trim())
|
|
458
506
|
.filter(Boolean);
|
|
459
|
-
|
|
460
|
-
// No file diff (e.g. only merge metadata) — nothing actionable, but stay
|
|
461
|
-
// conservative and treat as daemon-affecting so we don't suppress a real warning.
|
|
462
|
-
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
463
|
-
}
|
|
464
|
-
const pkgs = new Set<string>();
|
|
465
|
-
// A non-package path that is NOT a recognized benign root file (marker/doc).
|
|
466
|
-
// Only these force daemon-affecting; benign markers/docs are ignored so a
|
|
467
|
-
// gitlink-moving root commit over a marker-only oss commit no longer over-warns.
|
|
468
|
-
let sawRuntimeAmbiguousNonPackage = false;
|
|
469
|
-
for (const file of files) {
|
|
470
|
-
const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
|
|
471
|
-
if (!match) {
|
|
472
|
-
if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
pkgs.add(match[1]);
|
|
476
|
-
}
|
|
477
|
-
const affectedPackages = [...pkgs].sort();
|
|
478
|
-
// Daemon-affecting if: any runtime-ambiguous non-package file changed, any
|
|
479
|
-
// unknown package changed, or any explicit daemon-runtime package changed.
|
|
480
|
-
// The daemon is unaffected only when every changed file is either a known
|
|
481
|
-
// web-only package or a recognized benign root file (and at least one such
|
|
482
|
-
// file changed) — i.e. nothing runtime-ambiguous remains.
|
|
483
|
-
const allBenign =
|
|
484
|
-
!sawRuntimeAmbiguousNonPackage &&
|
|
485
|
-
affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
|
|
486
|
-
return { isDaemonAffecting: !allBenign, affectedPackages };
|
|
507
|
+
return classifyChangedFileList(files, policy);
|
|
487
508
|
} catch {
|
|
488
509
|
// diff probe failed → can't prove web-only; stay conservative.
|
|
489
510
|
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
490
511
|
}
|
|
491
512
|
}
|
|
492
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Ref-parameterized change-impact classification for a repo/worktree, reusing the
|
|
516
|
+
* exact daemon-vs-web bucketing that the stale-build detector uses — but over an
|
|
517
|
+
* arbitrary `fromRef..toRef` range (e.g. a refine base head → branch head) instead
|
|
518
|
+
* of the live daemon's build commit → HEAD, and WITHOUT any daemonBuildInfo caching.
|
|
519
|
+
*
|
|
520
|
+
* Policy is resolved the same way as getGitRepoStatus: an explicit
|
|
521
|
+
* `options.changeImpactConfig` wins; otherwise the repo's `.adhdev/change-impact.*`
|
|
522
|
+
* is auto-loaded; otherwise the built-in ADHDev default policy applies. The
|
|
523
|
+
* classification uses `git diff --name-only fromRef..toRef`.
|
|
524
|
+
*
|
|
525
|
+
* FAIL-OPEN on error: if the diff can't be collected (bad ref, not a repo), the
|
|
526
|
+
* caller should treat "no verdict" as "run everything" — so we throw rather than
|
|
527
|
+
* returning a misleading benign verdict. Callers wrap this in try/catch and leave
|
|
528
|
+
* changeImpact undefined on failure. Unclassified/new packages still default to
|
|
529
|
+
* isDaemonAffecting:true (never silently skipped).
|
|
530
|
+
*/
|
|
531
|
+
export async function classifyChangedPackages(
|
|
532
|
+
repoPath: string,
|
|
533
|
+
fromRef: string,
|
|
534
|
+
toRef: string,
|
|
535
|
+
options: GitStatusOptions = {},
|
|
536
|
+
): Promise<ChangedPackageClassification> {
|
|
537
|
+
const repo = await resolveGitRepository(repoPath, options);
|
|
538
|
+
const { config } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
|
|
539
|
+
const policy = resolveChangeImpactPolicy(config);
|
|
540
|
+
const diff = await runGit(repoPath, ['diff', '--name-only', `${fromRef}..${toRef}`], options);
|
|
541
|
+
const files = diff.stdout
|
|
542
|
+
.split('\n')
|
|
543
|
+
.map((line) => line.trim())
|
|
544
|
+
.filter(Boolean);
|
|
545
|
+
return classifyChangedFileList(files, policy);
|
|
546
|
+
}
|
|
547
|
+
|
|
493
548
|
/**
|
|
494
549
|
* Resolve the Change Impact config to apply for this status read. Priority:
|
|
495
550
|
* - options.changeImpactConfig === null → force built-in default policy (no load).
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
buildNoProgressCompletionReconciliation,
|
|
25
25
|
} from './mesh-events-stale.js';
|
|
26
26
|
import { endTaskDispatchInFlight } from './mesh-task-inflight.js';
|
|
27
|
+
import { readMeshNodeDaemonId } from './mesh-node-identity.js';
|
|
27
28
|
import {
|
|
28
29
|
buildMeshSystemMessage,
|
|
29
30
|
readNonEmptyString,
|
|
@@ -716,6 +717,58 @@ function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: strin
|
|
|
716
717
|
}
|
|
717
718
|
}
|
|
718
719
|
|
|
720
|
+
/**
|
|
721
|
+
* REDRIVE-DUP: stop a worker session that started a STALE (reclaimed) mesh dispatch,
|
|
722
|
+
* so it discards the reclaimed task before it double-executes it. Prefers the local
|
|
723
|
+
* transport when the session's adapter lives on this daemon; otherwise forwards a
|
|
724
|
+
* `stop_cli` to the worker node's daemon over P2P (best-effort — a failed stop only
|
|
725
|
+
* loses the belt-and-suspenders stop; the ack was already rejected, so the coordinator
|
|
726
|
+
* never treats the stale run as the authoritative execution).
|
|
727
|
+
*/
|
|
728
|
+
function stopStaleMeshWorker(
|
|
729
|
+
components: DaemonComponents,
|
|
730
|
+
args: { meshId: string; sessionId: string; nodeId?: string; providerType?: string; daemonId?: string },
|
|
731
|
+
): void {
|
|
732
|
+
const { meshId, sessionId, providerType } = args;
|
|
733
|
+
const stopArgs: Record<string, unknown> = {
|
|
734
|
+
targetSessionId: sessionId,
|
|
735
|
+
...(providerType ? { cliType: providerType } : {}),
|
|
736
|
+
mode: 'hard',
|
|
737
|
+
reason: 'stale_mesh_dispatch_reclaimed',
|
|
738
|
+
};
|
|
739
|
+
try {
|
|
740
|
+
const isLocal = components.cliManager?.adapters?.has?.(sessionId) === true;
|
|
741
|
+
if (isLocal) {
|
|
742
|
+
// cliType is required by stop_cli; resolve it from the local adapter when the
|
|
743
|
+
// event carried no providerType.
|
|
744
|
+
if (!stopArgs.cliType) {
|
|
745
|
+
const localType = components.cliManager?.adapters?.get?.(sessionId)?.cliType;
|
|
746
|
+
if (localType) stopArgs.cliType = localType;
|
|
747
|
+
}
|
|
748
|
+
Promise.resolve(components.cliManager?.handleCliCommand?.('stop_cli', stopArgs))
|
|
749
|
+
.catch((e: any) => LOG.warn('MeshQueue', `Local stop of stale worker ${sessionId} failed: ${e?.message || e}`));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Remote: resolve the worker node's daemon id (event metadata first, then the mesh node).
|
|
753
|
+
let daemonId = args.daemonId;
|
|
754
|
+
if (!daemonId && args.nodeId) {
|
|
755
|
+
try {
|
|
756
|
+
const mesh = getMeshWithCache(components, meshId);
|
|
757
|
+
const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, args.nodeId!));
|
|
758
|
+
daemonId = node ? readMeshNodeDaemonId(node) || undefined : undefined;
|
|
759
|
+
} catch { /* best-effort */ }
|
|
760
|
+
}
|
|
761
|
+
if (daemonId && components.dispatchMeshCommand) {
|
|
762
|
+
Promise.resolve(components.dispatchMeshCommand(daemonId, 'stop_cli', stopArgs))
|
|
763
|
+
.catch((e: any) => LOG.warn('MeshQueue', `Remote stop of stale worker ${sessionId} on daemon ${daemonId} failed: ${e?.message || e}`));
|
|
764
|
+
} else {
|
|
765
|
+
LOG.warn('MeshQueue', `Cannot stop stale worker ${sessionId}: no local adapter and no resolvable remote daemon id (node ${args.nodeId ?? '?'}). Ack already rejected — task will re-strand-and-fail if the worker completes.`);
|
|
766
|
+
}
|
|
767
|
+
} catch (e: any) {
|
|
768
|
+
LOG.warn('MeshQueue', `stopStaleMeshWorker error for ${sessionId}: ${e?.message || e}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
719
772
|
function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
720
773
|
meshId: string;
|
|
721
774
|
sourceInstanceId?: string;
|
|
@@ -1115,6 +1168,45 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1115
1168
|
// sibling must keep that row 'dispatched' so its own confirm can match it; acking
|
|
1116
1169
|
// by session would mark it 'acked' prematurely and hide a genuine non-delivery.
|
|
1117
1170
|
const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
|
|
1171
|
+
// REDRIVE-DUP: reject a STALE dispatch. When a delivered-but-unconsumed task is
|
|
1172
|
+
// reclaimed (reclaimStrandedAssignedTask) and re-dispatched to another node, the
|
|
1173
|
+
// ORIGINAL inject to the first node is not cancelled — it can still fire and make
|
|
1174
|
+
// that worker start the SAME taskId, double-executing it. The reclaim bumped the
|
|
1175
|
+
// task row's dispatchNonce, so the stranded inject's generating_started echoes a
|
|
1176
|
+
// nonce STRICTLY LESS than the row's current value. Detect that here: skip the ack
|
|
1177
|
+
// (do NOT resurrect the row onto this stale session) and stop the worker so it
|
|
1178
|
+
// discards the reclaimed task. A matching/greater nonce, or an absent nonce
|
|
1179
|
+
// (legacy worker), falls through to the normal ack — backward safe.
|
|
1180
|
+
const startedNonce = typeof args.metadataEvent.dispatchNonce === 'number'
|
|
1181
|
+
? args.metadataEvent.dispatchNonce
|
|
1182
|
+
: undefined;
|
|
1183
|
+
if (startedTaskId && startedNonce !== undefined) {
|
|
1184
|
+
const currentRow = (() => {
|
|
1185
|
+
try { return MeshRuntimeStore.getInstance().findQueueEntryById(args.meshId, startedTaskId); }
|
|
1186
|
+
catch { return null; }
|
|
1187
|
+
})();
|
|
1188
|
+
const currentNonce = typeof currentRow?.dispatchNonce === 'number' ? currentRow.dispatchNonce : undefined;
|
|
1189
|
+
if (currentNonce !== undefined && startedNonce < currentNonce) {
|
|
1190
|
+
LOG.warn('MeshQueue', `Rejecting stale mesh dispatch: task ${startedTaskId} generating_started from session ${sessionId} `
|
|
1191
|
+
+ `(node ${nodeId ?? '?'}) carries dispatchNonce ${startedNonce} < current ${currentNonce} — the task was reclaimed and `
|
|
1192
|
+
+ `re-dispatched; stopping this worker to prevent duplicate execution.`);
|
|
1193
|
+
traceMeshEventDrop('stale_dispatch_nonce_rejected', {
|
|
1194
|
+
taskId: startedTaskId,
|
|
1195
|
+
sessionId,
|
|
1196
|
+
nodeId,
|
|
1197
|
+
meshId: args.meshId,
|
|
1198
|
+
event: 'agent:generating_started',
|
|
1199
|
+
}, `nonce ${startedNonce} < ${currentNonce}`);
|
|
1200
|
+
stopStaleMeshWorker(components, {
|
|
1201
|
+
meshId: args.meshId,
|
|
1202
|
+
sessionId,
|
|
1203
|
+
nodeId,
|
|
1204
|
+
providerType: readNonEmptyString(args.metadataEvent.providerType) || readNonEmptyString(args.metadataEvent.cliType),
|
|
1205
|
+
daemonId: readNonEmptyString(args.metadataEvent.sourceDaemonId) || readNonEmptyString(args.metadataEvent.daemonId),
|
|
1206
|
+
});
|
|
1207
|
+
return { success: true, forwarded: 0, suppressed: true, staleDispatchRejected: true };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1118
1210
|
// WARMUPGAP: only ack a dispatch row when the event names its task, or the session
|
|
1119
1211
|
// currently holds an active assignment. A no-taskId generating_started from an
|
|
1120
1212
|
// unassigned session is a pre-assignment warmup — the session_id fallback would ack a
|
|
@@ -588,6 +588,10 @@ export function tryAssignQueueTask(
|
|
|
588
588
|
meshId,
|
|
589
589
|
nodeId,
|
|
590
590
|
taskId: task.id,
|
|
591
|
+
// REDRIVE-DUP: carry the current dispatch nonce so the worker can echo it
|
|
592
|
+
// back on generating_started; a reclaim bumps this row's nonce, making an
|
|
593
|
+
// already-in-flight stale inject rejectable on arrival.
|
|
594
|
+
...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
|
|
591
595
|
...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
|
|
592
596
|
...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
|
|
593
597
|
},
|
|
@@ -668,6 +672,8 @@ export function tryAssignQueueTask(
|
|
|
668
672
|
meshId,
|
|
669
673
|
nodeId,
|
|
670
674
|
taskId: task.id,
|
|
675
|
+
// REDRIVE-DUP: carry the current dispatch nonce (see remote branch above).
|
|
676
|
+
...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
|
|
671
677
|
...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
|
|
672
678
|
...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
|
|
673
679
|
},
|