@adhdev/daemon-core 0.9.82-rc.262 → 0.9.82-rc.264
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/build-info.d.ts +37 -0
- package/dist/git/git-status.d.ts +7 -0
- package/dist/git/git-types.d.ts +29 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +589 -66
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +585 -66
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +18 -0
- package/dist/mesh/mesh-fast-forward.d.ts +41 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/dist/mesh/mesh-work-queue.d.ts +6 -0
- package/package.json +1 -1
- package/src/build-info.ts +73 -0
- package/src/commands/router.ts +123 -4
- package/src/git/git-status.ts +163 -1
- package/src/git/git-types.ts +30 -0
- package/src/index.ts +5 -2
- package/src/mesh/mesh-active-work.ts +31 -0
- package/src/mesh/mesh-fast-forward.ts +418 -17
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-runtime-store.ts +19 -0
- package/src/mesh/mesh-work-queue.ts +13 -0
|
@@ -83,6 +83,24 @@ export declare function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): {
|
|
|
83
83
|
terminalDirectWork: MeshActiveWorkRecord[];
|
|
84
84
|
summary: MeshActiveWorkSummary;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* staleReason strings (produced by sessionStatusFromNodes above) that indicate the original
|
|
88
|
+
* node/session is GONE from the live mesh — i.e. the staleDirect record is an orphaned ledger
|
|
89
|
+
* artifact, not active or recoverable work. These are the only reasons safe to prune from the
|
|
90
|
+
* active staleDirect surface. The "no provider acknowledgement" reason is deliberately excluded:
|
|
91
|
+
* those entries have a still-live node/session (staleDispatchUnacknowledged) and represent
|
|
92
|
+
* recoverable dispatch failures, never orphans.
|
|
93
|
+
*/
|
|
94
|
+
export declare const PRUNABLE_ORPHAN_STALE_REASONS: ReadonlySet<string>;
|
|
95
|
+
export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_terminal' | 'preserve_unacknowledged' | 'preserve_active';
|
|
96
|
+
/**
|
|
97
|
+
* Classify a direct-work record for the staleDirect prune path. Pure function — the prune tool
|
|
98
|
+
* uses this so the safety rules (never touch active work or recoverable unacknowledged dispatches)
|
|
99
|
+
* live next to the staleReason producers and are independently testable.
|
|
100
|
+
*/
|
|
101
|
+
export declare function classifyStaleDirectForPrune(record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>, opts?: {
|
|
102
|
+
includeTerminal?: boolean;
|
|
103
|
+
}): StaleDirectPruneClassification;
|
|
86
104
|
export declare function buildCompactStaleDirectWorkSummary(staleDirectWork: MeshActiveWorkRecord[], opts?: {
|
|
87
105
|
sampleLimit?: number;
|
|
88
106
|
detailHint?: string;
|
|
@@ -10,19 +10,50 @@ export interface MeshFastForwardNodeArgs {
|
|
|
10
10
|
submoduleIgnorePaths?: string[];
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
trigger?: 'manual' | 'idle_auto' | string;
|
|
13
|
+
/**
|
|
14
|
+
* Operation mode. 'merge' (default) absorbs upstream commits into the local
|
|
15
|
+
* branch via git merge --ff-only (requires ahead=0, behind>0). 'push' publishes
|
|
16
|
+
* local commits to origin via a strict ff-only push (requires HEAD to be a
|
|
17
|
+
* descendant of origin/<branch>); it never force-pushes, resets, or rebases.
|
|
18
|
+
*/
|
|
19
|
+
mode?: 'merge' | 'push';
|
|
20
|
+
/**
|
|
21
|
+
* When mode='push', also fast-forward push submodule (e.g. oss) HEADs to their
|
|
22
|
+
* origin main branch. Gated by allowAutoPublishSubmoduleMainCommits — skipped
|
|
23
|
+
* unless that policy is true. Each submodule must still pass the descendant gate.
|
|
24
|
+
* Defaults false (root push only).
|
|
25
|
+
*/
|
|
26
|
+
pushSubmodules?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Mesh policy flag mirrored from RepoMeshPolicy.allowAutoPublishSubmoduleMainCommits.
|
|
29
|
+
* Submodule pushes are refused unless this is true.
|
|
30
|
+
*/
|
|
31
|
+
allowAutoPublishSubmoduleMainCommits?: boolean;
|
|
13
32
|
}
|
|
14
33
|
export interface MeshFastForwardPlannedStep {
|
|
15
|
-
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
|
|
34
|
+
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status' | 'verify_push_descendant' | 'push_ff_only' | 'push_submodules_ff_only';
|
|
16
35
|
description: string;
|
|
17
36
|
safe: true;
|
|
18
37
|
willMutateWorktree: boolean;
|
|
19
38
|
}
|
|
39
|
+
export interface MeshFastForwardSubmodulePushResult {
|
|
40
|
+
path: string;
|
|
41
|
+
commit?: string;
|
|
42
|
+
remote: string;
|
|
43
|
+
remoteBranch: string;
|
|
44
|
+
pushed: boolean;
|
|
45
|
+
skipped: boolean;
|
|
46
|
+
code: string;
|
|
47
|
+
refspec?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
20
50
|
export interface MeshFastForwardResult {
|
|
21
51
|
success: boolean;
|
|
22
52
|
code: string;
|
|
23
53
|
nodeId?: string;
|
|
24
54
|
meshId?: string;
|
|
25
55
|
workspace: string;
|
|
56
|
+
mode: 'merge' | 'push';
|
|
26
57
|
allowed: boolean;
|
|
27
58
|
dryRun: boolean;
|
|
28
59
|
willRun: boolean;
|
|
@@ -33,9 +64,18 @@ export interface MeshFastForwardResult {
|
|
|
33
64
|
current?: GitRepoStatus;
|
|
34
65
|
preStatus?: GitRepoStatus;
|
|
35
66
|
postStatus?: GitRepoStatus;
|
|
67
|
+
/** Push target derived from the tracked upstream (mode='push'). */
|
|
68
|
+
pushTarget?: {
|
|
69
|
+
remote: string;
|
|
70
|
+
remoteBranch: string;
|
|
71
|
+
refspec: string;
|
|
72
|
+
};
|
|
73
|
+
/** Submodule ff-only push outcomes (mode='push' with pushSubmodules). */
|
|
74
|
+
submodulePushes?: MeshFastForwardSubmodulePushResult[];
|
|
36
75
|
finalBranchConvergenceState?: Record<string, unknown>;
|
|
37
76
|
operationError?: string;
|
|
38
77
|
ledgerError?: string;
|
|
78
|
+
nextStep?: string;
|
|
39
79
|
trigger?: string;
|
|
40
80
|
}
|
|
41
81
|
export declare function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult>;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
16
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
|
-
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable';
|
|
17
|
+
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned';
|
|
18
18
|
export interface MeshLedgerEntry {
|
|
19
19
|
id: string;
|
|
20
20
|
meshId: string;
|
|
@@ -72,6 +72,12 @@ export declare class MeshRuntimeStore {
|
|
|
72
72
|
updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
|
|
73
73
|
cleanupTerminalDirectDispatches(olderThanMs: number): void;
|
|
74
74
|
deleteDirectDispatches(meshId: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
|
|
77
|
+
* path to remove orphaned/terminal dispatch records whose node/session is no longer in the
|
|
78
|
+
* live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
|
|
79
|
+
*/
|
|
80
|
+
deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number;
|
|
75
81
|
markStaleDirectDispatches(meshId: string, olderThanMs: number): void;
|
|
76
82
|
setRemoteIdleSession(nodeId: string, sessionId: string, providerType: string, expiresAt: number, metadata?: any): void;
|
|
77
83
|
getRemoteIdleSessions(): Array<{
|
|
@@ -243,6 +243,12 @@ export declare function getActiveDirectDispatches(meshId: string): DirectDispatc
|
|
|
243
243
|
export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
|
|
244
244
|
export declare function cleanupTerminalDirectDispatches(olderThanMs?: number): void;
|
|
245
245
|
export declare function markStaleDirectDispatches(meshId: string, olderThanMs?: number): void;
|
|
246
|
+
/**
|
|
247
|
+
* Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
|
|
248
|
+
* Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
|
|
249
|
+
* active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
|
|
250
|
+
*/
|
|
251
|
+
export declare function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number;
|
|
246
252
|
export type MeshToolCallRateResult = {
|
|
247
253
|
rateLimitExceeded: boolean;
|
|
248
254
|
callsInWindow: number;
|
package/package.json
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon build stamp — single runtime source for "which commit is this running
|
|
3
|
+
* daemon built from?".
|
|
4
|
+
*
|
|
5
|
+
* The values are injected at BUILD time via tsup `define` (esbuild global
|
|
6
|
+
* replacement). Every build config that bundles daemon-core into a shippable
|
|
7
|
+
* daemon (daemon-core's own dist, daemon-standalone, daemon-cloud) replaces
|
|
8
|
+
* these `__DAEMON_BUILD_*` identifiers with the literal git commit/version that
|
|
9
|
+
* was current when the bundle was produced. See:
|
|
10
|
+
* - oss/packages/daemon-core/tsup.config.ts (standalone consumes this dist)
|
|
11
|
+
* - packages/daemon-cloud/tsup.config.ts (re-bundles daemon-core from source)
|
|
12
|
+
*
|
|
13
|
+
* If a bundle is produced WITHOUT the define (e.g. running src directly through
|
|
14
|
+
* tsx in dev, or a build env without git), the identifiers stay undefined and
|
|
15
|
+
* we fall back to `"unknown"` — never a ReferenceError, never a build failure.
|
|
16
|
+
*
|
|
17
|
+
* IMPORTANT (live-reflection caveat): a fresh local `daemon-core dist` rebuild +
|
|
18
|
+
* daemon restart is NOT enough to make a *cloud* daemon report a new commit —
|
|
19
|
+
* daemon-cloud ships its own re-bundle of daemon-core, so the build stamp only
|
|
20
|
+
* advances after the cloud daemon is rebuilt/redeployed and restarted. This is
|
|
21
|
+
* precisely the gap `mesh_status`'s `staleDaemonBuild` warning exists to surface.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// These are replaced at build time by tsup `define`. `typeof` guards keep this
|
|
25
|
+
// safe when the define is absent (the identifiers are simply not declared).
|
|
26
|
+
declare const __DAEMON_BUILD_COMMIT__: string | undefined;
|
|
27
|
+
declare const __DAEMON_BUILD_COMMIT_SHORT__: string | undefined;
|
|
28
|
+
declare const __DAEMON_BUILD_VERSION__: string | undefined;
|
|
29
|
+
declare const __DAEMON_BUILD_AT__: string | undefined;
|
|
30
|
+
|
|
31
|
+
export interface DaemonBuildInfo {
|
|
32
|
+
/** Full 40-char git commit the daemon bundle was built from, or 'unknown'. */
|
|
33
|
+
commit: string;
|
|
34
|
+
/** Short (7-char) form of the same commit, or 'unknown'. */
|
|
35
|
+
commitShort: string;
|
|
36
|
+
/** package.json version baked in at build time, or 'unknown'. */
|
|
37
|
+
version: string;
|
|
38
|
+
/** ISO build timestamp if the build config injected one. */
|
|
39
|
+
builtAt?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readInjected(value: string | undefined): string | undefined {
|
|
43
|
+
if (typeof value !== 'string') return undefined;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (!trimmed || trimmed === 'unknown') return undefined;
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let cached: DaemonBuildInfo | undefined;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the build stamp baked into the running daemon bundle. Pure read of
|
|
53
|
+
* build-time constants — no I/O, safe to call from any runtime path. Cached.
|
|
54
|
+
*/
|
|
55
|
+
export function getDaemonBuildInfo(): DaemonBuildInfo {
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
|
|
58
|
+
const commit =
|
|
59
|
+
readInjected(typeof __DAEMON_BUILD_COMMIT__ !== 'undefined' ? __DAEMON_BUILD_COMMIT__ : undefined)
|
|
60
|
+
?? 'unknown';
|
|
61
|
+
const commitShort =
|
|
62
|
+
readInjected(typeof __DAEMON_BUILD_COMMIT_SHORT__ !== 'undefined' ? __DAEMON_BUILD_COMMIT_SHORT__ : undefined)
|
|
63
|
+
?? (commit !== 'unknown' ? commit.slice(0, 7) : 'unknown');
|
|
64
|
+
const version =
|
|
65
|
+
readInjected(typeof __DAEMON_BUILD_VERSION__ !== 'undefined' ? __DAEMON_BUILD_VERSION__ : undefined)
|
|
66
|
+
// Fall back to the runtime-injected package version env used elsewhere.
|
|
67
|
+
?? readInjected(typeof process !== 'undefined' ? process.env?.ADHDEV_PKG_VERSION : undefined)
|
|
68
|
+
?? 'unknown';
|
|
69
|
+
const builtAt = readInjected(typeof __DAEMON_BUILD_AT__ !== 'undefined' ? __DAEMON_BUILD_AT__ : undefined);
|
|
70
|
+
|
|
71
|
+
cached = builtAt ? { commit, commitShort, version, builtAt } : { commit, commitShort, version };
|
|
72
|
+
return cached;
|
|
73
|
+
}
|
package/src/commands/router.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
type WorktreeBootstrapState,
|
|
65
65
|
} from '../mesh/worktree-bootstrap-config.js';
|
|
66
66
|
import { buildMachineInfo, buildStatusSnapshot } from '../status/snapshot.js';
|
|
67
|
+
import { getDaemonBuildInfo } from '../build-info.js';
|
|
67
68
|
import { getSessionCompletionMarker } from '../status/snapshot.js';
|
|
68
69
|
import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
|
|
69
70
|
import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
|
|
@@ -3521,6 +3522,8 @@ export class DaemonCommandRouter {
|
|
|
3521
3522
|
const skippedSessionIds: string[] = [];
|
|
3522
3523
|
const skippedLiveSessionIds: string[] = [];
|
|
3523
3524
|
const skippedCoordinatorSessionIds: string[] = [];
|
|
3525
|
+
const skippedLiveSessionReasons: Array<{ sessionId: string; reason: string }> = [];
|
|
3526
|
+
const actedLiveDelegateSessionIds: string[] = [];
|
|
3524
3527
|
const deleteUnsupportedSessionIds: string[] = [];
|
|
3525
3528
|
const recordsRemainSessionIds: string[] = [];
|
|
3526
3529
|
const errors: Array<{ sessionId: string; error: string }> = [];
|
|
@@ -3556,16 +3559,49 @@ export class DaemonCommandRouter {
|
|
|
3556
3559
|
const surfaceKind = getSessionHostSurfaceKind(record);
|
|
3557
3560
|
const liveRuntime = surfaceKind === 'live_runtime';
|
|
3558
3561
|
const coordinatorSession = readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId;
|
|
3562
|
+
// A delegate session was launched by the coordinator specifically FOR this node
|
|
3563
|
+
// (meta.meshNodeId === this node). It is 1:1 bound to the node, even when the node
|
|
3564
|
+
// shares its daemon runtime with the main/other nodes. Removing the node should be
|
|
3565
|
+
// able to stop its own delegate session — the shared-daemon concern only applies to
|
|
3566
|
+
// sessions we matched by workspace alone (which could belong to the coordinator or to
|
|
3567
|
+
// a sibling node that is still active).
|
|
3568
|
+
const recordNodeId = readStringValue(record?.meta?.meshNodeId);
|
|
3569
|
+
const recordMeshNodeFor = readStringValue(record?.meta?.meshNodeFor);
|
|
3570
|
+
const delegateBoundToThisNode = !!recordNodeId
|
|
3571
|
+
&& recordNodeId === args.nodeId
|
|
3572
|
+
&& (!recordMeshNodeFor || recordMeshNodeFor === args.meshId);
|
|
3559
3573
|
if (!hasExplicitSessionIds && coordinatorSession) {
|
|
3560
3574
|
skippedSessionIds.push(sessionId);
|
|
3561
3575
|
skippedCoordinatorSessionIds.push(sessionId);
|
|
3562
3576
|
continue;
|
|
3563
3577
|
}
|
|
3564
|
-
|
|
3578
|
+
// Only the conservative shared-daemon guard for live sessions that are NOT a delegate
|
|
3579
|
+
// explicitly bound to this node. Delegate-bound live sessions fall through and are
|
|
3580
|
+
// stopped/deleted by the mode handlers below (which already record an intentional stop).
|
|
3581
|
+
if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode) {
|
|
3565
3582
|
skippedSessionIds.push(sessionId);
|
|
3566
3583
|
skippedLiveSessionIds.push(sessionId);
|
|
3584
|
+
const matchedByWorkspaceOnly = !recordNodeId;
|
|
3585
|
+
const reason = recordNodeId && recordNodeId !== args.nodeId
|
|
3586
|
+
? `live_delegate_bound_to_other_node:${recordNodeId}`
|
|
3587
|
+
: matchedByWorkspaceOnly
|
|
3588
|
+
? 'live_session_matched_by_workspace_only_no_node_binding'
|
|
3589
|
+
: 'live_session_not_bound_to_this_node';
|
|
3590
|
+
skippedLiveSessionReasons.push({ sessionId, reason });
|
|
3567
3591
|
continue;
|
|
3568
3592
|
}
|
|
3593
|
+
if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
|
|
3594
|
+
// delete_stopped never stops live runtimes by contract — even bound delegates.
|
|
3595
|
+
// Surface a clear reason instead of an unexplained skip so callers know to use
|
|
3596
|
+
// stop / stop_and_delete to release a still-running bound delegate.
|
|
3597
|
+
skippedSessionIds.push(sessionId);
|
|
3598
|
+
skippedLiveSessionIds.push(sessionId);
|
|
3599
|
+
skippedLiveSessionReasons.push({ sessionId, reason: 'live_delegate_preserved_by_delete_stopped_mode_use_stop_or_stop_and_delete' });
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode) {
|
|
3603
|
+
actedLiveDelegateSessionIds.push(sessionId);
|
|
3604
|
+
}
|
|
3569
3605
|
try {
|
|
3570
3606
|
if (args.mode === 'stop') {
|
|
3571
3607
|
if (!completed) {
|
|
@@ -3631,6 +3667,8 @@ export class DaemonCommandRouter {
|
|
|
3631
3667
|
skippedSessionIds,
|
|
3632
3668
|
skippedLiveSessionIds,
|
|
3633
3669
|
skippedCoordinatorSessionIds,
|
|
3670
|
+
...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
|
|
3671
|
+
...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
|
|
3634
3672
|
...(deleteUnsupported ? {
|
|
3635
3673
|
deleteUnsupported: true,
|
|
3636
3674
|
effectiveCleanup: args.mode === 'stop_and_delete'
|
|
@@ -4489,10 +4527,46 @@ export class DaemonCommandRouter {
|
|
|
4489
4527
|
}
|
|
4490
4528
|
|
|
4491
4529
|
const cleanupStarted = Date.now();
|
|
4530
|
+
// Honor the mesh policy for delegated-session cleanup on the auto-removed
|
|
4531
|
+
// worktree node (previously hardcoded to 'preserve', which orphaned the
|
|
4532
|
+
// delegate session as an idle record on the coordinator daemon). Fall back
|
|
4533
|
+
// to 'preserve' when no policy is set.
|
|
4534
|
+
const refineSessionCleanupMode = this.normalizeMeshSessionCleanupMode(
|
|
4535
|
+
mesh?.policy?.sessionCleanupOnNodeRemove,
|
|
4536
|
+
);
|
|
4537
|
+
// The delegate session launched for a clone worktree is frequently matched
|
|
4538
|
+
// by workspace ONLY (no meta.meshNodeId binding), which remove_mesh_node's
|
|
4539
|
+
// shared-daemon guard skips. Since refine knows exactly which workspace it
|
|
4540
|
+
// just merged, collect that workspace's live session ids explicitly and pass
|
|
4541
|
+
// them through — explicit sessionIds bypass the workspace-only-match guard so
|
|
4542
|
+
// the policy-driven stop/delete actually runs.
|
|
4543
|
+
let refineSessionIds: string[] | undefined;
|
|
4544
|
+
if (refineSessionCleanupMode !== 'preserve' && this.deps.sessionHostControl) {
|
|
4545
|
+
try {
|
|
4546
|
+
const liveSessions = await this.deps.sessionHostControl.listSessions();
|
|
4547
|
+
const workspace = typeof node.workspace === 'string' ? node.workspace : '';
|
|
4548
|
+
refineSessionIds = liveSessions
|
|
4549
|
+
.filter((record: any) => {
|
|
4550
|
+
const sid = typeof record?.sessionId === 'string' ? record.sessionId : '';
|
|
4551
|
+
if (!sid) return false;
|
|
4552
|
+
// Never sweep the coordinator's own session for this mesh.
|
|
4553
|
+
if (readStringValue(record?.meta?.meshCoordinatorFor) === meshId) return false;
|
|
4554
|
+
const boundToNode = readStringValue(record?.meta?.meshNodeId) === nodeId;
|
|
4555
|
+
const matchedByWorkspace = !!workspace && record?.workspace === workspace;
|
|
4556
|
+
return boundToNode || matchedByWorkspace;
|
|
4557
|
+
})
|
|
4558
|
+
.map((record: any) => String(record.sessionId));
|
|
4559
|
+
} catch {
|
|
4560
|
+
// listSessions failure is non-fatal — fall back to the policy-mode
|
|
4561
|
+
// cleanup without explicit ids (still better than hardcoded preserve).
|
|
4562
|
+
refineSessionIds = undefined;
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4492
4565
|
const removeResult = await this.execute('remove_mesh_node', {
|
|
4493
4566
|
meshId,
|
|
4494
4567
|
nodeId,
|
|
4495
|
-
sessionCleanupMode:
|
|
4568
|
+
sessionCleanupMode: refineSessionCleanupMode,
|
|
4569
|
+
...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
|
|
4496
4570
|
inlineMesh: args?.inlineMesh,
|
|
4497
4571
|
});
|
|
4498
4572
|
recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
|
|
@@ -5783,7 +5857,11 @@ export class DaemonCommandRouter {
|
|
|
5783
5857
|
version: this.deps.statusVersion || 'unknown',
|
|
5784
5858
|
profile: 'metadata',
|
|
5785
5859
|
});
|
|
5786
|
-
|
|
5860
|
+
// Surface the daemon's build stamp so coordinators (mesh_status)
|
|
5861
|
+
// can detect a running daemon that predates a just-merged fix and
|
|
5862
|
+
// is awaiting deploy/restart. Sibling of `status` to avoid
|
|
5863
|
+
// perturbing the dashboard status snapshot shape.
|
|
5864
|
+
return { success: true, status: snapshot, daemonBuild: getDaemonBuildInfo() };
|
|
5787
5865
|
}
|
|
5788
5866
|
|
|
5789
5867
|
case 'get_machine_runtime_stats': {
|
|
@@ -6886,6 +6964,7 @@ export class DaemonCommandRouter {
|
|
|
6886
6964
|
? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
|
|
6887
6965
|
: undefined;
|
|
6888
6966
|
let nodeDaemonId: string | undefined;
|
|
6967
|
+
let allowAutoPublishSubmoduleMainCommits = false;
|
|
6889
6968
|
if (meshId && nodeId) {
|
|
6890
6969
|
// preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
|
|
6891
6970
|
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
@@ -6897,6 +6976,7 @@ export class DaemonCommandRouter {
|
|
|
6897
6976
|
if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
|
|
6898
6977
|
submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
|
|
6899
6978
|
}
|
|
6979
|
+
allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
|
|
6900
6980
|
nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
|
|
6901
6981
|
}
|
|
6902
6982
|
// If the target node belongs to a remote daemon, forward the command there.
|
|
@@ -6921,6 +7001,9 @@ export class DaemonCommandRouter {
|
|
|
6921
7001
|
dryRun: args?.dryRun === true,
|
|
6922
7002
|
updateSubmodules: args?.updateSubmodules === true,
|
|
6923
7003
|
submoduleIgnorePaths,
|
|
7004
|
+
mode: args?.mode === 'push' ? 'push' : 'merge',
|
|
7005
|
+
pushSubmodules: args?.pushSubmodules === true,
|
|
7006
|
+
allowAutoPublishSubmoduleMainCommits,
|
|
6924
7007
|
}) as Promise<unknown>);
|
|
6925
7008
|
return result as CommandRouterResult;
|
|
6926
7009
|
}
|
|
@@ -6929,6 +7012,29 @@ export class DaemonCommandRouter {
|
|
|
6929
7012
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
6930
7013
|
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
6931
7014
|
if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
|
|
7015
|
+
// Dry-run (plan-only) is the default and stays synchronous: it does no
|
|
7016
|
+
// validation/merge/push and returns the plan instantly. Only execute=true
|
|
7017
|
+
// (and not dry_run) goes through the async refine job that actually
|
|
7018
|
+
// validates → merges → pushes → cleans up. Mirrors the
|
|
7019
|
+
// batch_refine_mesh_nodes / fast_forward_mesh_node dry_run/execute contract.
|
|
7020
|
+
const isDryRun = args?.dryRun !== false && args?.execute !== true;
|
|
7021
|
+
if (isDryRun) {
|
|
7022
|
+
// preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
|
|
7023
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
7024
|
+
const mesh = meshRecord?.mesh;
|
|
7025
|
+
const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
|
|
7026
|
+
if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
|
|
7027
|
+
return {
|
|
7028
|
+
success: true,
|
|
7029
|
+
dryRun: true,
|
|
7030
|
+
nodeId,
|
|
7031
|
+
workspace: node.workspace,
|
|
7032
|
+
validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
|
|
7033
|
+
mergeWillRun: false,
|
|
7034
|
+
cleanupWillRun: false,
|
|
7035
|
+
hint: 'Dry-run only — no merge/push/cleanup performed. Re-invoke with execute:true to converge this node.',
|
|
7036
|
+
};
|
|
7037
|
+
}
|
|
6932
7038
|
return this.startMeshRefineJob(meshId, nodeId, args);
|
|
6933
7039
|
}
|
|
6934
7040
|
|
|
@@ -6961,9 +7067,22 @@ export class DaemonCommandRouter {
|
|
|
6961
7067
|
const sessionCleanupMode = this.normalizeMeshSessionCleanupMode(
|
|
6962
7068
|
args?.sessionCleanupMode ?? args?.session_cleanup_mode ?? mesh?.policy?.sessionCleanupOnNodeRemove,
|
|
6963
7069
|
);
|
|
7070
|
+
// Explicit sessionIds (e.g. supplied by refine auto-cleanup) bypass the
|
|
7071
|
+
// workspace-only-match guard so a delegate session that lacks a
|
|
7072
|
+
// meta.meshNodeId binding can still be stopped/deleted.
|
|
7073
|
+
const explicitSessionIds = Array.isArray(args?.sessionIds)
|
|
7074
|
+
? (args.sessionIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
|
|
7075
|
+
: undefined;
|
|
6964
7076
|
let sessionCleanup: Record<string, unknown> | undefined;
|
|
6965
7077
|
if (node && sessionCleanupMode !== 'preserve') {
|
|
6966
|
-
sessionCleanup = await this.cleanupMeshSessions({
|
|
7078
|
+
sessionCleanup = await this.cleanupMeshSessions({
|
|
7079
|
+
meshId,
|
|
7080
|
+
nodeId,
|
|
7081
|
+
node,
|
|
7082
|
+
mode: sessionCleanupMode,
|
|
7083
|
+
...(explicitSessionIds && explicitSessionIds.length > 0 ? { sessionIds: explicitSessionIds } : {}),
|
|
7084
|
+
source: 'mesh_remove_node',
|
|
7085
|
+
});
|
|
6967
7086
|
if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
|
|
6968
7087
|
}
|
|
6969
7088
|
|
package/src/git/git-status.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
|
|
1
|
+
import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
|
|
2
2
|
import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
|
|
3
|
+
import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
|
|
3
4
|
|
|
4
5
|
type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
|
|
5
6
|
|
|
@@ -14,6 +15,12 @@ export interface GitStatusOptions {
|
|
|
14
15
|
* Callers should opt into this only for convergence-critical surfaces.
|
|
15
16
|
*/
|
|
16
17
|
refreshUpstream?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Test/override seam for the daemon build stamp used by the stale-build
|
|
20
|
+
* detector. Production callers omit this so the real baked-in build commit
|
|
21
|
+
* (getDaemonBuildInfo) is used.
|
|
22
|
+
*/
|
|
23
|
+
daemonBuildInfo?: DaemonBuildInfo;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
interface GitUpstreamProbe {
|
|
@@ -54,6 +61,8 @@ export async function getGitRepoStatus(
|
|
|
54
61
|
|| stashCount > 0
|
|
55
62
|
|| submoduleDirty;
|
|
56
63
|
|
|
64
|
+
const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options);
|
|
65
|
+
|
|
57
66
|
return {
|
|
58
67
|
workspace: repo.workspace,
|
|
59
68
|
repoRoot: repo.repoRoot,
|
|
@@ -78,6 +87,7 @@ export async function getGitRepoStatus(
|
|
|
78
87
|
stashCount,
|
|
79
88
|
lastCheckedAt,
|
|
80
89
|
submodules,
|
|
90
|
+
...(daemonBuildBehind ? { daemonBuildBehind } : {}),
|
|
81
91
|
};
|
|
82
92
|
} catch (error) {
|
|
83
93
|
if (error instanceof GitCommandError) {
|
|
@@ -91,6 +101,158 @@ export async function getGitRepoStatus(
|
|
|
91
101
|
}
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Detect whether the running daemon's build commit is a STRICT ancestor of this
|
|
106
|
+
* workspace's HEAD (root) or any of its submodules' HEAD. This surfaces the
|
|
107
|
+
* "merged a fix to main but the live daemon still ships the old bundle" gap:
|
|
108
|
+
* once the fix is committed, the workspace HEAD advances past the daemon's
|
|
109
|
+
* baked-in build commit, but the daemon keeps the old behavior until it is
|
|
110
|
+
* rebuilt/redeployed and restarted.
|
|
111
|
+
*
|
|
112
|
+
* Conservative by construction — returns undefined unless ancestry is provable:
|
|
113
|
+
* - build commit unknown → undefined
|
|
114
|
+
* - build commit not an object in this repo/submodule (different repo) → skip
|
|
115
|
+
* - build commit === HEAD (daemon is current) → undefined
|
|
116
|
+
* - build commit NOT an ancestor of HEAD (daemon ahead / diverged) → undefined
|
|
117
|
+
* Any git error is swallowed (no warning) so a flaky probe never over-warns.
|
|
118
|
+
*/
|
|
119
|
+
/**
|
|
120
|
+
* Package names that, when changed, mean the daemon runtime is stale and must be
|
|
121
|
+
* rebuilt/redeployed + restarted. Everything NOT in this set (web-core,
|
|
122
|
+
* web-standalone, web-devconsole, terminal-render-web) is web-only — a daemon
|
|
123
|
+
* restart is not required for those, only a web redeploy. mcp-server runs in the
|
|
124
|
+
* same process surface as the daemon tooling, so it is classified as
|
|
125
|
+
* daemon-affecting (conservative). Unknown package → daemon-affecting.
|
|
126
|
+
*/
|
|
127
|
+
const DAEMON_RUNTIME_PACKAGES = new Set([
|
|
128
|
+
'daemon-core',
|
|
129
|
+
'daemon-standalone',
|
|
130
|
+
'session-host-core',
|
|
131
|
+
'session-host-daemon',
|
|
132
|
+
'terminal-mux-core',
|
|
133
|
+
'terminal-mux-control',
|
|
134
|
+
'terminal-mux-cli',
|
|
135
|
+
'ghostty-vt-node',
|
|
136
|
+
'mcp-server',
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const WEB_ONLY_PACKAGES = new Set([
|
|
140
|
+
'web-core',
|
|
141
|
+
'web-standalone',
|
|
142
|
+
'web-devconsole',
|
|
143
|
+
'terminal-render-web',
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
|
|
148
|
+
* package. Returns isDaemonAffecting:true conservatively when the changed-file set
|
|
149
|
+
* can't be obtained or any changed path is outside the known web-only package set
|
|
150
|
+
* (including root-level / non-package files).
|
|
151
|
+
*/
|
|
152
|
+
async function classifyDaemonBuildChange(
|
|
153
|
+
repoPath: string,
|
|
154
|
+
buildCommit: string,
|
|
155
|
+
options: GitStatusOptions,
|
|
156
|
+
): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
|
|
157
|
+
try {
|
|
158
|
+
const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
|
|
159
|
+
const files = diff.stdout
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
if (files.length === 0) {
|
|
164
|
+
// No file diff (e.g. only merge metadata) — nothing actionable, but stay
|
|
165
|
+
// conservative and treat as daemon-affecting so we don't suppress a real warning.
|
|
166
|
+
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
167
|
+
}
|
|
168
|
+
const pkgs = new Set<string>();
|
|
169
|
+
let sawNonPackageOrUnknown = false;
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
|
|
172
|
+
if (!match) {
|
|
173
|
+
sawNonPackageOrUnknown = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
pkgs.add(match[1]);
|
|
177
|
+
}
|
|
178
|
+
const affectedPackages = [...pkgs].sort();
|
|
179
|
+
// Daemon-affecting if: any non-package/root file changed, any unknown package
|
|
180
|
+
// changed, or any explicit daemon-runtime package changed. Only when EVERY
|
|
181
|
+
// changed file maps to a known web-only package is the daemon unaffected.
|
|
182
|
+
const allWebOnly =
|
|
183
|
+
!sawNonPackageOrUnknown &&
|
|
184
|
+
affectedPackages.length > 0 &&
|
|
185
|
+
affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
|
|
186
|
+
return { isDaemonAffecting: !allWebOnly, affectedPackages };
|
|
187
|
+
} catch {
|
|
188
|
+
// diff probe failed → can't prove web-only; stay conservative.
|
|
189
|
+
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function detectDaemonBuildBehind(
|
|
194
|
+
repo: ResolvedGitRepo,
|
|
195
|
+
submodules: GitSubmoduleStatus[] | undefined,
|
|
196
|
+
options: GitStatusOptions,
|
|
197
|
+
): Promise<DaemonBuildBehind | undefined> {
|
|
198
|
+
const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
|
|
199
|
+
if (!build.commit || build.commit === 'unknown') return undefined;
|
|
200
|
+
|
|
201
|
+
// Check the root repo first, then each submodule. The daemon build commit is
|
|
202
|
+
// baked from the daemon-core (oss submodule) HEAD, so on an adhdev
|
|
203
|
+
// superproject worktree the match is expected on the `oss` submodule, not the
|
|
204
|
+
// root — checking both keeps the helper repo-agnostic.
|
|
205
|
+
const scopes: Array<{ scope: string; repoPath: string }> = [
|
|
206
|
+
{ scope: 'root', repoPath: repo.repoRoot || repo.workspace },
|
|
207
|
+
];
|
|
208
|
+
for (const sub of submodules || []) {
|
|
209
|
+
if (sub.repoPath && !sub.error) scopes.push({ scope: sub.path, repoPath: sub.repoPath });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const { scope, repoPath } of scopes) {
|
|
213
|
+
try {
|
|
214
|
+
// Build commit must be a real object in THIS repo, else it's a different repo.
|
|
215
|
+
await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
|
|
216
|
+
const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
|
|
217
|
+
const head = headResult.stdout.trim();
|
|
218
|
+
if (!head || head === build.commit) continue;
|
|
219
|
+
// Strict ancestor: build commit is reachable from HEAD but is not HEAD.
|
|
220
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
|
|
221
|
+
// No throw → build commit IS an ancestor of HEAD → daemon is behind.
|
|
222
|
+
// Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
|
|
223
|
+
// is only actually required when a daemon-runtime package changed; if only web /
|
|
224
|
+
// render packages changed, the daemon is unaffected and just the web deploy is
|
|
225
|
+
// pending. Conservative: any probe failure → treat as daemon-affecting.
|
|
226
|
+
const { isDaemonAffecting, affectedPackages } = await classifyDaemonBuildChange(
|
|
227
|
+
repoPath,
|
|
228
|
+
build.commit,
|
|
229
|
+
options,
|
|
230
|
+
);
|
|
231
|
+
const scopeLabel = scope === 'root' ? 'workspace' : scope;
|
|
232
|
+
const warning = isDaemonAffecting
|
|
233
|
+
? `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}. ` +
|
|
234
|
+
`Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`
|
|
235
|
+
: `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}, ` +
|
|
236
|
+
`but only web packages changed (${(affectedPackages || []).join(', ') || 'web'}). ` +
|
|
237
|
+
`Daemon restart NOT required — redeploy the web app to reflect the change.`;
|
|
238
|
+
return {
|
|
239
|
+
buildCommit: build.commit,
|
|
240
|
+
buildCommitShort: build.commitShort,
|
|
241
|
+
head,
|
|
242
|
+
scope,
|
|
243
|
+
isDaemonAffecting,
|
|
244
|
+
...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
|
|
245
|
+
warning,
|
|
246
|
+
};
|
|
247
|
+
} catch {
|
|
248
|
+
// cat-file / merge-base non-zero exit (commit absent or not an ancestor)
|
|
249
|
+
// or any git error → not a provable staleness for this scope; try next.
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
94
256
|
interface ParsedPorcelainStatus {
|
|
95
257
|
branch: string | null;
|
|
96
258
|
upstream: string | null;
|