@adhdev/daemon-core 0.9.82-rc.315 → 0.9.82-rc.317
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 +23 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +9 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +309 -48
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -52
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/dist/mesh/mesh-work-queue.d.ts +22 -0
- package/dist/repo-mesh-types.d.ts +68 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.ts +25 -0
- package/src/cli-adapters/provider-cli-adapter.ts +36 -1
- package/src/cli-adapters/provider-cli-shared.ts +19 -2
- package/src/commands/router.ts +42 -1
- package/src/config/mesh-config.ts +72 -1
- package/src/git/git-status.ts +122 -41
- package/src/index.ts +7 -1
- package/src/mesh/mesh-events-coordinator.ts +5 -1
- package/src/mesh/mesh-reconcile-loop.ts +31 -1
- package/src/mesh/mesh-runtime-store.ts +13 -0
- package/src/mesh/mesh-work-queue.ts +89 -11
- package/src/repo-mesh-types.ts +112 -0
|
@@ -41,6 +41,12 @@ export declare class MeshRuntimeStore {
|
|
|
41
41
|
* untargeted work spreads instead of piling onto whichever node asks first.
|
|
42
42
|
*/
|
|
43
43
|
nodeActiveAssignmentCount(meshId: string, nodeId: string): number;
|
|
44
|
+
/**
|
|
45
|
+
* O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
|
|
46
|
+
* indexed status column, so it avoids JSON.parse-ing every queue row — used as a
|
|
47
|
+
* cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
|
|
48
|
+
*/
|
|
49
|
+
pendingQueueTaskCount(meshId: string): number;
|
|
44
50
|
/**
|
|
45
51
|
* Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
|
|
46
52
|
* the tie-break winner among nodes tied at the least load.
|
|
@@ -108,6 +108,28 @@ export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabil
|
|
|
108
108
|
export declare function resolveConvergeRequiredTags(meshId: string, taskMode: MeshTaskMode | undefined, explicitRequiredTags: string[], opts?: {
|
|
109
109
|
targetNodeId?: string;
|
|
110
110
|
}): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Declarative task_mode → role affinity resolution (precedent: resolveConvergeRequiredTags).
|
|
113
|
+
*
|
|
114
|
+
* At enqueue time, auto-inject a `role=<role>` required tag derived from the task's
|
|
115
|
+
* taskMode via the mesh's policy.taskAffinity (falling back to DEFAULT_TASKMODE_ROLE_MAP),
|
|
116
|
+
* so the task hard-filters onto nodes advertising that role through the ordinary
|
|
117
|
+
* required-tags path (nodeSatisfiesRequiredTags) — no claim/scheduler change needed.
|
|
118
|
+
*
|
|
119
|
+
* Strict precedence / backward compatibility — the injection is skipped (returns the
|
|
120
|
+
* explicit tags unchanged) when ANY of:
|
|
121
|
+
* - the caller pinned an explicit targetNodeId (operator chose the node), or
|
|
122
|
+
* - the caller supplied their own non-empty required_tags (caller's routing wins), or
|
|
123
|
+
* - affinity resolves to no role (policy disabled, blank override, or unknown mode), or
|
|
124
|
+
* - SOFT fallback: no node in the mesh advertises the resolved role=<role> — injecting
|
|
125
|
+
* would block the task with zero eligible nodes, so we skip it and let least_loaded
|
|
126
|
+
* eligibility take over (a warning is logged so the misconfiguration is visible).
|
|
127
|
+
* Idempotent: normalizeMeshCapabilityTags dedupes.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveTaskAffinityRequiredTags(meshId: string, taskMode: MeshTaskMode | undefined, explicitRequiredTags: string[], opts?: {
|
|
130
|
+
targetNodeId?: string;
|
|
131
|
+
callerSpecifiedRequiredTags?: boolean;
|
|
132
|
+
}): string[];
|
|
111
133
|
/**
|
|
112
134
|
* M1: detect dependency cycles before enqueue. Walks the dependency graph of
|
|
113
135
|
* existing queue entries plus the new task's edges. Fail-closed: a cycle
|
|
@@ -119,6 +119,62 @@ export declare function resolveNodeSchedulingPriority(nodePolicy: Pick<RepoMeshN
|
|
|
119
119
|
*/
|
|
120
120
|
export declare const MESH_CONVERGE_REFINE_TAG = "converge=refine";
|
|
121
121
|
export declare const MESH_CONVERGE_FAST_FORWARD_TAG = "converge=fast_forward";
|
|
122
|
+
/**
|
|
123
|
+
* Standard mesh resource-pool roles. These are the dashboard-provided defaults for
|
|
124
|
+
* the role=<x> routing tag (advertised by node policy.providerRoles and matched
|
|
125
|
+
* through nodeSatisfiesRequiredTags). A mesh is free to declare additional custom
|
|
126
|
+
* role strings in policy.taskAffinity; the dashboard role dropdown exposes the union
|
|
127
|
+
* of these four standards plus any config-declared roles. Roles are lowercased
|
|
128
|
+
* everywhere (the role=<x> tag is lowercased in roleCapabilityTags).
|
|
129
|
+
*/
|
|
130
|
+
export declare const STANDARD_MESH_ROLES: readonly ["investigator", "coder", "validator", "converger"];
|
|
131
|
+
export type StandardMeshRole = typeof STANDARD_MESH_ROLES[number];
|
|
132
|
+
/**
|
|
133
|
+
* Default taskMode → role mapping (all five task modes). A task enqueued with a
|
|
134
|
+
* given taskMode auto-routes to a node advertising the mapped role=<x> tag, unless
|
|
135
|
+
* the mesh overrides the mapping in policy.taskAffinity.byTaskMode. Keyed by the
|
|
136
|
+
* MeshTaskMode string literals (the canonical union lives in mesh-work-queue.ts;
|
|
137
|
+
* this map is keyed by string to avoid a types ↔ queue import cycle). Kept in sync
|
|
138
|
+
* with MeshTaskMode by the exhaustive lookup in resolveTaskAffinityRole.
|
|
139
|
+
*/
|
|
140
|
+
export declare const DEFAULT_TASKMODE_ROLE_MAP: Readonly<Record<string, StandardMeshRole>>;
|
|
141
|
+
/**
|
|
142
|
+
* Declarative task_mode → role affinity policy. When set, enqueueTask auto-injects a
|
|
143
|
+
* `role=<role>` required tag (see resolveTaskAffinityRequiredTags) so the task
|
|
144
|
+
* hard-filters onto nodes advertising that role — but only when the caller did NOT
|
|
145
|
+
* already pin the task with an explicit target_node_id or its own required_tags.
|
|
146
|
+
*
|
|
147
|
+
* - `enabled`: master switch. Defaults to true so the built-in DEFAULT_TASKMODE_ROLE_MAP
|
|
148
|
+
* takes effect even when a mesh declares no taskAffinity block at all. Set false to
|
|
149
|
+
* fully disable affinity routing (today's pre-affinity behavior).
|
|
150
|
+
* - `byTaskMode`: per-taskMode override of the default mapping. A role string here may
|
|
151
|
+
* be any of STANDARD_MESH_ROLES or a custom role declared by the operator. An empty
|
|
152
|
+
* string disables affinity for that one task mode.
|
|
153
|
+
* - `customRoles`: extra (non-standard) role strings to surface in the dashboard role
|
|
154
|
+
* dropdown. Routing itself does not require a role to be pre-declared here — any role
|
|
155
|
+
* referenced in byTaskMode is injected regardless; customRoles only widens the UI list.
|
|
156
|
+
*/
|
|
157
|
+
export interface RepoMeshTaskAffinityPolicy {
|
|
158
|
+
enabled?: boolean;
|
|
159
|
+
byTaskMode?: Record<string, string>;
|
|
160
|
+
customRoles?: string[];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the affinity role for a task mode against a mesh's taskAffinity policy.
|
|
164
|
+
* Returns the lowercased role string to inject as `role=<role>`, or null when no
|
|
165
|
+
* affinity applies (affinity disabled, an explicit empty override, or an unknown mode).
|
|
166
|
+
*
|
|
167
|
+
* Precedence: policy.byTaskMode[mode] (operator override) → DEFAULT_TASKMODE_ROLE_MAP[mode].
|
|
168
|
+
* A blank override string explicitly opts that mode out of affinity routing.
|
|
169
|
+
*/
|
|
170
|
+
export declare function resolveTaskAffinityRole(taskMode: string | undefined, policy: RepoMeshTaskAffinityPolicy | null | undefined): string | null;
|
|
171
|
+
/**
|
|
172
|
+
* Union of the standard roles plus any operator-declared roles (byTaskMode values +
|
|
173
|
+
* customRoles), lowercased and deduped, preserving standard-first ordering. Used by
|
|
174
|
+
* the dashboard role dropdown so operators can pick a standard or a config-declared
|
|
175
|
+
* custom role. Pure/UI-facing — does not gate routing.
|
|
176
|
+
*/
|
|
177
|
+
export declare function resolveMeshRoleOptions(policy: RepoMeshTaskAffinityPolicy | null | undefined): string[];
|
|
122
178
|
/**
|
|
123
179
|
* Resolve whether the load-balancing scheduler should auto-inject a
|
|
124
180
|
* `converge=refine` required tag onto code_change tasks so they hard-filter onto
|
|
@@ -168,6 +224,18 @@ export interface RepoMeshPolicy {
|
|
|
168
224
|
* Defaults to false: code_change routing is unchanged unless opted in.
|
|
169
225
|
*/
|
|
170
226
|
autoConvergeCodeChange?: boolean;
|
|
227
|
+
/**
|
|
228
|
+
* Declarative task_mode → role affinity routing. When present (and not disabled),
|
|
229
|
+
* enqueueTask auto-injects a `role=<role>` required tag for the task's taskMode so
|
|
230
|
+
* the work hard-filters onto nodes advertising that role. Explicit target_node_id
|
|
231
|
+
* routing and any caller-supplied required_tags are preserved (auto-injection only
|
|
232
|
+
* applies when the caller pinned neither). When the mapped role has no advertising
|
|
233
|
+
* node in the mesh, the task is NOT blocked — the role tag is skipped so the work
|
|
234
|
+
* falls back to ordinary least_loaded eligibility (soft affinity). Defaults to the
|
|
235
|
+
* built-in DEFAULT_TASKMODE_ROLE_MAP even when this block is omitted; set
|
|
236
|
+
* `{ enabled: false }` to fully restore pre-affinity routing.
|
|
237
|
+
*/
|
|
238
|
+
taskAffinity?: RepoMeshTaskAffinityPolicy;
|
|
171
239
|
/**
|
|
172
240
|
* Whether sessions spawned by mesh/coordinator policy should auto-open as visible
|
|
173
241
|
* dashboard tabs or start hidden. Defaults to 'visible' to preserve existing
|
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.317",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.317",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -38,6 +38,29 @@ export interface AcpAdapterHandle {
|
|
|
38
38
|
resolvePermission?(approved: boolean): Promise<void>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Launch metadata for a CLI session, surfaced by the dashboard Session info panel.
|
|
43
|
+
* Derived from the live adapter's spawn plan — the resolved binary, the full
|
|
44
|
+
* argument vector (provider base args + per-launch extra args), the cwd, and the
|
|
45
|
+
* set of per-launch extra-env KEYS (values are intentionally omitted so secrets in
|
|
46
|
+
* extraEnv are never sent to the dashboard). providerSessionId is the upstream
|
|
47
|
+
* agent's own session id once the CLI reports it.
|
|
48
|
+
*/
|
|
49
|
+
export interface CliLaunchInfo {
|
|
50
|
+
/** Resolved executable path the PTY actually spawns. */
|
|
51
|
+
command?: string;
|
|
52
|
+
/** Full argument vector (provider spawn.args + extraArgs, {{workingDir}} expanded). */
|
|
53
|
+
args: string[];
|
|
54
|
+
/** Per-launch extra args only (subset of args), for attribution. */
|
|
55
|
+
extraArgs: string[];
|
|
56
|
+
/** Working directory the session was spawned in. */
|
|
57
|
+
cwd: string;
|
|
58
|
+
/** KEYS of per-launch extra env (values omitted — may contain secrets). */
|
|
59
|
+
extraEnvKeys: string[];
|
|
60
|
+
/** Upstream agent session id, once the CLI reports one. */
|
|
61
|
+
providerSessionId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
export interface CliAdapter {
|
|
42
65
|
cliType: string;
|
|
43
66
|
cliName: string;
|
|
@@ -70,6 +93,8 @@ export interface CliAdapter {
|
|
|
70
93
|
setOnPtyData?(callback: (data: string) => void): void;
|
|
71
94
|
writeRaw?(data: string): void;
|
|
72
95
|
resize?(cols: number, rows: number): void;
|
|
96
|
+
// ── Launch metadata for the dashboard Session info panel (args/cwd/env keys) ──
|
|
97
|
+
getLaunchInfo?(): CliLaunchInfo;
|
|
73
98
|
// ── Runtime metadata used by CliProviderInstance for session tracking ──
|
|
74
99
|
getRuntimeMetadata?(): unknown;
|
|
75
100
|
updateRuntimeMeta?(meta: Record<string, unknown>): void;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as os from 'os';
|
|
18
|
-
import type { CliAdapter } from '../cli-adapter-types.js';
|
|
18
|
+
import type { CliAdapter, CliLaunchInfo } from '../cli-adapter-types.js';
|
|
19
19
|
import type { InteractivePromptResponse } from '../providers/types/interactive-prompt.js';
|
|
20
20
|
import { LOG } from '../logging/logger.js';
|
|
21
21
|
import { getDebugRuntimeConfig } from '../logging/debug-config.js';
|
|
@@ -1595,6 +1595,41 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1595
1595
|
return this.ptyProcess.getMetadata();
|
|
1596
1596
|
}
|
|
1597
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* Launch metadata for the dashboard Session info panel. Re-derives the spawn plan
|
|
1600
|
+
* (pure — same inputs the live PTY was spawned with) so the dashboard sees the
|
|
1601
|
+
* resolved binary, full arg vector, and cwd without us having to persist it.
|
|
1602
|
+
* extraEnv values are intentionally dropped (keys only) so secrets passed at
|
|
1603
|
+
* launch time never reach the dashboard.
|
|
1604
|
+
*/
|
|
1605
|
+
getLaunchInfo(): CliLaunchInfo {
|
|
1606
|
+
let command: string | undefined;
|
|
1607
|
+
let args: string[] = [...this.extraArgs];
|
|
1608
|
+
try {
|
|
1609
|
+
const plan = resolveCliSpawnPlan({
|
|
1610
|
+
provider: this.provider,
|
|
1611
|
+
runtimeSettings: this.runtimeSettings,
|
|
1612
|
+
workingDir: this.workingDir,
|
|
1613
|
+
extraArgs: this.extraArgs,
|
|
1614
|
+
extraEnv: this.extraEnv,
|
|
1615
|
+
});
|
|
1616
|
+
command = plan.binaryPath;
|
|
1617
|
+
args = plan.allArgs;
|
|
1618
|
+
} catch {
|
|
1619
|
+
// Spawn plan resolution can throw before the binary is resolvable
|
|
1620
|
+
// (e.g. CLI not installed). Fall back to the raw extra args so the
|
|
1621
|
+
// panel still shows what it can rather than failing the whole call.
|
|
1622
|
+
}
|
|
1623
|
+
return {
|
|
1624
|
+
command,
|
|
1625
|
+
args,
|
|
1626
|
+
extraArgs: [...this.extraArgs],
|
|
1627
|
+
cwd: this.workingDir,
|
|
1628
|
+
extraEnvKeys: Object.keys(this.extraEnv || {}),
|
|
1629
|
+
providerSessionId: this.providerSessionId || undefined,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1598
1633
|
updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
|
|
1599
1634
|
const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
|
|
1600
1635
|
? meta.providerSessionId.trim()
|
|
@@ -534,9 +534,26 @@ export function findBinary(name: string): string {
|
|
|
534
534
|
}
|
|
535
535
|
const isWin = os.platform() === 'win32';
|
|
536
536
|
const paths = (process.env.PATH || '').split(path.delimiter);
|
|
537
|
+
// Also search well-known global-bin directories that are frequently NOT on
|
|
538
|
+
// the daemon's inherited PATH. A daemon running under one Node install (e.g.
|
|
539
|
+
// nvm) never sees another npm prefix's bin dir — notably npm's Windows
|
|
540
|
+
// default prefix at %APPDATA%\npm, where `npm i -g @openai/codex` lands. We
|
|
541
|
+
// append these after PATH (so explicit PATH entries still win) so that an
|
|
542
|
+
// npm-global CLI resolves to an absolute path; the spawn layer's .cmd-shim
|
|
543
|
+
// handling then launches it correctly regardless of PATH.
|
|
544
|
+
const extraDirs: string[] = [];
|
|
545
|
+
if (isWin) {
|
|
546
|
+
if (process.env.APPDATA) extraDirs.push(path.join(process.env.APPDATA, 'npm'));
|
|
547
|
+
try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
|
|
548
|
+
} else {
|
|
549
|
+
extraDirs.push(path.join(os.homedir(), '.npm-global', 'bin'));
|
|
550
|
+
extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
|
|
551
|
+
try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
|
|
552
|
+
}
|
|
553
|
+
const searchDirs = [...paths, ...extraDirs];
|
|
537
554
|
const exes = isWin ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
538
|
-
|
|
539
|
-
for (const p of
|
|
555
|
+
|
|
556
|
+
for (const p of searchDirs) {
|
|
540
557
|
if (!p) continue;
|
|
541
558
|
for (const ext of exes) {
|
|
542
559
|
const fullPath = path.join(p, trimmed + ext);
|
package/src/commands/router.ts
CHANGED
|
@@ -1094,6 +1094,25 @@ function readMeshConnectionState(connection: Record<string, unknown> | null | un
|
|
|
1094
1094
|
return readStringValue((connection as any)?.state);
|
|
1095
1095
|
}
|
|
1096
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Connection states that mean the peer is definitively NOT reachable right now —
|
|
1099
|
+
* an offline machine (no peer entry at all) or a transport that has dropped
|
|
1100
|
+
* (failed/closed/disconnected). Probing such a peer would just burn the full
|
|
1101
|
+
* MESH_DIRECT_PROBE_TIMEOUT_MS window before timing out, so the cold-open of the
|
|
1102
|
+
* mesh graph stalls 25s behind one powered-off node. `connecting` is deliberately
|
|
1103
|
+
* NOT here: a peer mid-handshake may complete during the probe window, so it still
|
|
1104
|
+
* gets its attempt. Held standing git truth is consulted by the caller BEFORE this
|
|
1105
|
+
* runs, so the invariant "connected+held is never unavailable" is untouched — this
|
|
1106
|
+
* only short-circuits a peer that has no usable transport to probe over.
|
|
1107
|
+
*/
|
|
1108
|
+
function isMeshConnectionDefinitivelyDown(
|
|
1109
|
+
connection: Record<string, unknown> | null | undefined,
|
|
1110
|
+
): boolean {
|
|
1111
|
+
if (!connection) return true;
|
|
1112
|
+
const state = readMeshConnectionState(connection);
|
|
1113
|
+
return state === 'failed' || state === 'closed' || state === 'disconnected';
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1097
1116
|
/**
|
|
1098
1117
|
* Probe a remote peer's git_status with a bounded retry budget, but only while
|
|
1099
1118
|
* the peer is reported `connected`. A single slow (often TURN-relayed) peer can
|
|
@@ -1117,6 +1136,19 @@ async function probeRemoteMeshGitStatusWithRetry(args: {
|
|
|
1117
1136
|
getConnection?: (daemonId: string) => Record<string, unknown> | null;
|
|
1118
1137
|
onConnection?: (connection: Record<string, unknown>) => void;
|
|
1119
1138
|
}): Promise<Record<string, unknown> | null> {
|
|
1139
|
+
// Fast-fail an offline / dropped peer BEFORE the first attempt. Previously the
|
|
1140
|
+
// liveness re-check only ran *between* attempts, so a powered-off node still ate
|
|
1141
|
+
// the full first MESH_DIRECT_PROBE_TIMEOUT_MS (25s) window — stalling the mesh
|
|
1142
|
+
// graph cold-open behind one dead machine. If a connection getter is wired and
|
|
1143
|
+
// it reports the peer as definitively down (no peer entry / failed / closed /
|
|
1144
|
+
// disconnected), skip straight to "no truth" instead of awaiting a 25s timeout.
|
|
1145
|
+
// A `connecting` peer still gets its attempt (it may complete mid-probe). No
|
|
1146
|
+
// onConnection side effect here: this is a pure liveness gate, and the caller's
|
|
1147
|
+
// own connection read already seeds status.connection — only the between-attempt
|
|
1148
|
+
// path needs to surface a freshly-observed connection.
|
|
1149
|
+
if (args.getConnection && isMeshConnectionDefinitivelyDown(args.getConnection(args.daemonId))) {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1120
1152
|
for (let attempt = 0; attempt <= MESH_DIRECT_PROBE_MAX_RETRIES; attempt += 1) {
|
|
1121
1153
|
if (attempt > 0) {
|
|
1122
1154
|
// Re-check liveness before spending another probe window; a peer that
|
|
@@ -6345,6 +6377,12 @@ export class DaemonCommandRouter {
|
|
|
6345
6377
|
const runtimeMeta = (adapter && typeof (adapter as any).getRuntimeMetadata === 'function')
|
|
6346
6378
|
? (adapter as any).getRuntimeMetadata()
|
|
6347
6379
|
: undefined;
|
|
6380
|
+
// Launch metadata (args / cwd / extra-env keys / providerSessionId) is
|
|
6381
|
+
// derived from the live adapter's spawn plan; only available while the
|
|
6382
|
+
// adapter is alive (resumed-from-history sessions report nothing here).
|
|
6383
|
+
const launchInfo = (adapter && typeof (adapter as any).getLaunchInfo === 'function')
|
|
6384
|
+
? (adapter as any).getLaunchInfo()
|
|
6385
|
+
: undefined;
|
|
6348
6386
|
const providerType = target?.providerType || coord?.cliType || '';
|
|
6349
6387
|
const providerMetaForSession = providerType
|
|
6350
6388
|
? this.deps.providerLoader.resolve?.(providerType) || this.deps.providerLoader.getMeta(providerType)
|
|
@@ -6358,8 +6396,11 @@ export class DaemonCommandRouter {
|
|
|
6358
6396
|
transport: target?.transport,
|
|
6359
6397
|
workspace: (target as any)?.workspace || coord?.workspace,
|
|
6360
6398
|
spawnedAtMs: (target as any)?.spawnedAtMs || coord?.startedAt,
|
|
6361
|
-
providerSessionId
|
|
6399
|
+
// providerSessionId now comes from the live adapter's launch info
|
|
6400
|
+
// (the registry target never carried it — it was always undefined).
|
|
6401
|
+
providerSessionId: launchInfo?.providerSessionId || (target as any)?.providerSessionId,
|
|
6362
6402
|
runtimeMetadata: runtimeMeta,
|
|
6403
|
+
launch: launchInfo,
|
|
6363
6404
|
},
|
|
6364
6405
|
coordinator: coord ? {
|
|
6365
6406
|
meshId: coord.meshId,
|
|
@@ -20,8 +20,9 @@ import type {
|
|
|
20
20
|
RepoMeshCoordinatorConfig,
|
|
21
21
|
RepoMeshHostMetadata,
|
|
22
22
|
RepoMeshDaemonRole,
|
|
23
|
+
RepoMeshTaskAffinityPolicy,
|
|
23
24
|
} from '../repo-mesh-types.js';
|
|
24
|
-
import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
25
|
+
import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy, STANDARD_MESH_ROLES } from '../repo-mesh-types.js';
|
|
25
26
|
import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
|
|
26
27
|
|
|
27
28
|
// ─── Persistence ────────────────────────────────
|
|
@@ -134,9 +135,79 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
|
|
|
134
135
|
} else {
|
|
135
136
|
delete policy.autoConvergeCodeChange;
|
|
136
137
|
}
|
|
138
|
+
// Task-mode → role affinity: format-only validation (drop blanks). Unknown/custom
|
|
139
|
+
// roles are NOT blocked — only a warning is logged. Only persist when the block
|
|
140
|
+
// carries meaningful content so existing meshes.json stays untouched.
|
|
141
|
+
const normalizedAffinity = normalizeTaskAffinityPolicy(policy.taskAffinity);
|
|
142
|
+
if (normalizedAffinity) {
|
|
143
|
+
policy.taskAffinity = normalizedAffinity;
|
|
144
|
+
} else {
|
|
145
|
+
delete policy.taskAffinity;
|
|
146
|
+
}
|
|
137
147
|
return policy;
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Format-only normalization of a task-affinity policy. Trims/lowercases role strings,
|
|
152
|
+
* drops blank custom roles and blank byTaskMode keys, and warns (never blocks) when a
|
|
153
|
+
* declared role is not one of the standard four. Returns undefined when the result is
|
|
154
|
+
* empty/no-op so the field stays absent from persisted config.
|
|
155
|
+
*/
|
|
156
|
+
function normalizeTaskAffinityPolicy(
|
|
157
|
+
value: unknown,
|
|
158
|
+
): RepoMeshTaskAffinityPolicy | undefined {
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
160
|
+
const record = value as Record<string, unknown>;
|
|
161
|
+
const out: RepoMeshTaskAffinityPolicy = {};
|
|
162
|
+
let hasContent = false;
|
|
163
|
+
|
|
164
|
+
if (typeof record.enabled === 'boolean') {
|
|
165
|
+
out.enabled = record.enabled;
|
|
166
|
+
// `enabled:true` is the default — only persist the explicit `false`.
|
|
167
|
+
if (record.enabled === false) hasContent = true;
|
|
168
|
+
else delete out.enabled;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (record.byTaskMode && typeof record.byTaskMode === 'object' && !Array.isArray(record.byTaskMode)) {
|
|
172
|
+
const byTaskMode: Record<string, string> = {};
|
|
173
|
+
for (const [mode, raw] of Object.entries(record.byTaskMode as Record<string, unknown>)) {
|
|
174
|
+
const modeKey = mode.trim();
|
|
175
|
+
if (!modeKey) continue;
|
|
176
|
+
if (typeof raw !== 'string') continue;
|
|
177
|
+
// A blank value is a deliberate opt-out for that mode — preserve it as ''.
|
|
178
|
+
const role = raw.trim().toLowerCase();
|
|
179
|
+
byTaskMode[modeKey] = role;
|
|
180
|
+
if (role && !STANDARD_MESH_ROLES.includes(role as any)) {
|
|
181
|
+
try {
|
|
182
|
+
console.warn(`[mesh] task_affinity.byTaskMode.${modeKey}: custom role "${role}" is not a standard mesh role (${STANDARD_MESH_ROLES.join('/')}); routing still applies`);
|
|
183
|
+
} catch { /* best-effort */ }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (Object.keys(byTaskMode).length > 0) {
|
|
187
|
+
out.byTaskMode = byTaskMode;
|
|
188
|
+
hasContent = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (Array.isArray(record.customRoles)) {
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
const customRoles: string[] = [];
|
|
195
|
+
for (const raw of record.customRoles) {
|
|
196
|
+
if (typeof raw !== 'string') continue;
|
|
197
|
+
const role = raw.trim().toLowerCase();
|
|
198
|
+
if (!role || seen.has(role)) continue;
|
|
199
|
+
seen.add(role);
|
|
200
|
+
customRoles.push(role);
|
|
201
|
+
}
|
|
202
|
+
if (customRoles.length > 0) {
|
|
203
|
+
out.customRoles = customRoles;
|
|
204
|
+
hasContent = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return hasContent ? out : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
|
|
141
212
|
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
142
213
|
? value as Record<string, unknown>
|
package/src/git/git-status.ts
CHANGED
|
@@ -560,13 +560,24 @@ async function getSubmoduleStatuses(
|
|
|
560
560
|
if (!repo.repoRoot) return [];
|
|
561
561
|
|
|
562
562
|
try {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
563
|
+
// Do NOT shell out to `git submodule status`. That porcelain wrapper is a
|
|
564
|
+
// shell script (`git-submodule`) that, per submodule, spawns several child
|
|
565
|
+
// `git` processes; on Windows the wrapper + per-spawn cost alone measured
|
|
566
|
+
// 6.9–62.4s under AV, which dominated the whole collectGitRepoStatus budget
|
|
567
|
+
// and stalled the mesh graph cold-open. The information it gives us — the
|
|
568
|
+
// gitlink sync state (path / recorded SHA / +/-/U prefix) — is fully
|
|
569
|
+
// derivable from plumbing commands that don't go through the shell wrapper:
|
|
570
|
+
// • paths ← `.gitmodules` (git config --file, plumbing)
|
|
571
|
+
// • expected SHA ← `git ls-tree HEAD <path>` (the gitlink the super-
|
|
572
|
+
// project's HEAD tree records)
|
|
573
|
+
// • actual SHA ← `git -C <sub> rev-parse HEAD` (already paid below by
|
|
574
|
+
// enrichSubmoduleWorktreeStatus for the dirty check)
|
|
575
|
+
// Comparing expected vs actual reproduces `+` (out of sync); a checked-out
|
|
576
|
+
// submodule whose worktree is absent/uninitialized reproduces `-`. The `U`
|
|
577
|
+
// (conflict) prefix is surfaced separately via the superproject porcelain
|
|
578
|
+
// status that the caller already parses, and a conflicted submodule's own
|
|
579
|
+
// status read here also flags it dirty — so no row is lost.
|
|
580
|
+
const submodules = await deriveSubmoduleGitlinkStatuses(repo, options);
|
|
570
581
|
await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
|
|
571
582
|
return submodules;
|
|
572
583
|
} catch {
|
|
@@ -574,6 +585,110 @@ async function getSubmoduleStatuses(
|
|
|
574
585
|
}
|
|
575
586
|
}
|
|
576
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Enumerate the superproject's submodules and their gitlink sync state without the
|
|
590
|
+
* slow `git submodule status` shell wrapper. Pure plumbing: read paths from
|
|
591
|
+
* `.gitmodules`, the expected (recorded) gitlink SHA from `ls-tree HEAD`, and the
|
|
592
|
+
* actual checked-out SHA from the submodule's own `rev-parse HEAD`.
|
|
593
|
+
*/
|
|
594
|
+
async function deriveSubmoduleGitlinkStatuses(
|
|
595
|
+
repo: ResolvedGitRepo,
|
|
596
|
+
options: GitStatusOptions,
|
|
597
|
+
): Promise<GitSubmoduleStatus[]> {
|
|
598
|
+
if (!repo.repoRoot) return [];
|
|
599
|
+
const paths = await readSubmodulePaths(repo, options);
|
|
600
|
+
const ignoreSet = new Set(options.submoduleIgnorePaths || []);
|
|
601
|
+
const lastCheckedAt = Date.now();
|
|
602
|
+
|
|
603
|
+
const entries = await Promise.all(
|
|
604
|
+
paths
|
|
605
|
+
.filter(path => !ignoreSet.has(path))
|
|
606
|
+
.map(async (path): Promise<GitSubmoduleStatus> => {
|
|
607
|
+
const repoPath = repo.repoRoot + '/' + path;
|
|
608
|
+
const expected = await readGitlinkExpectedSha(repo, path, options);
|
|
609
|
+
const actual = await readSubmoduleHeadSha(repo, repoPath, options);
|
|
610
|
+
// Uninitialized / no checked-out HEAD reproduces `git submodule status`'s
|
|
611
|
+
// `-` prefix; a present-but-divergent HEAD reproduces the `+` prefix.
|
|
612
|
+
const outOfSync = actual === null
|
|
613
|
+
? true
|
|
614
|
+
: expected !== null && expected !== actual;
|
|
615
|
+
return {
|
|
616
|
+
path,
|
|
617
|
+
// Prefer the recorded gitlink SHA (matches the legacy column); fall back
|
|
618
|
+
// to the checked-out SHA so the field is never empty when both are known.
|
|
619
|
+
commit: expected ?? actual ?? '',
|
|
620
|
+
repoPath,
|
|
621
|
+
dirty: false,
|
|
622
|
+
outOfSync,
|
|
623
|
+
lastCheckedAt,
|
|
624
|
+
};
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
return entries;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Read submodule paths from `.gitmodules` via plumbing (no shell wrapper). */
|
|
631
|
+
async function readSubmodulePaths(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<string[]> {
|
|
632
|
+
if (!repo.repoRoot) return [];
|
|
633
|
+
const gitmodulesPath = repo.repoRoot + '/.gitmodules';
|
|
634
|
+
try {
|
|
635
|
+
const result = await runGit(
|
|
636
|
+
repo,
|
|
637
|
+
['config', '--file', gitmodulesPath, '--get-regexp', '^submodule\\..*\\.path$'],
|
|
638
|
+
options,
|
|
639
|
+
);
|
|
640
|
+
const paths: string[] = [];
|
|
641
|
+
for (const line of result.stdout.split('\n')) {
|
|
642
|
+
// Each line: `submodule.<name>.path <path>`
|
|
643
|
+
const spaceIdx = line.indexOf(' ');
|
|
644
|
+
if (spaceIdx < 0) continue;
|
|
645
|
+
const value = line.slice(spaceIdx + 1).trim();
|
|
646
|
+
if (value) paths.push(value);
|
|
647
|
+
}
|
|
648
|
+
return paths;
|
|
649
|
+
} catch {
|
|
650
|
+
// No .gitmodules (not a superproject) or unreadable → no submodules.
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Expected gitlink SHA recorded in the superproject HEAD tree for this submodule path. */
|
|
656
|
+
async function readGitlinkExpectedSha(
|
|
657
|
+
repo: ResolvedGitRepo,
|
|
658
|
+
submodulePath: string,
|
|
659
|
+
options: GitStatusOptions,
|
|
660
|
+
): Promise<string | null> {
|
|
661
|
+
try {
|
|
662
|
+
// `ls-tree HEAD <path>` prints: `<mode> commit <sha>\t<path>` for a gitlink.
|
|
663
|
+
const result = await runGit(repo, ['ls-tree', 'HEAD', submodulePath], options);
|
|
664
|
+
const line = result.stdout.split('\n').find(l => l.trim().length > 0);
|
|
665
|
+
if (!line) return null;
|
|
666
|
+
const match = line.match(/^\s*\d+\s+commit\s+([0-9a-f]{40})\b/);
|
|
667
|
+
return match ? match[1] : null;
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Actual checked-out HEAD SHA of a submodule, or null if uninitialized/unreadable. */
|
|
674
|
+
async function readSubmoduleHeadSha(
|
|
675
|
+
repo: ResolvedGitRepo,
|
|
676
|
+
repoPath: string,
|
|
677
|
+
options: GitStatusOptions,
|
|
678
|
+
): Promise<string | null> {
|
|
679
|
+
try {
|
|
680
|
+
// Run in the submodule worktree via cwd (inside the superproject root, so the
|
|
681
|
+
// executor's path-inside-repo guard is satisfied) rather than resolving the
|
|
682
|
+
// submodule as a fresh repo — that would cost an extra `rev-parse --show-toplevel`
|
|
683
|
+
// spawn per submodule, which is exactly the Windows spawn cost this fix removes.
|
|
684
|
+
const result = await runGit(repo, ['rev-parse', 'HEAD'], { ...options, cwd: repoPath });
|
|
685
|
+
const sha = result.stdout.trim();
|
|
686
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
687
|
+
} catch {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
577
692
|
async function enrichSubmoduleWorktreeStatus(
|
|
578
693
|
repo: ResolvedGitRepo,
|
|
579
694
|
submodule: GitSubmoduleStatus,
|
|
@@ -594,37 +709,3 @@ async function enrichSubmoduleWorktreeStatus(
|
|
|
594
709
|
}
|
|
595
710
|
}
|
|
596
711
|
|
|
597
|
-
function parseSubmoduleStatusOutput(
|
|
598
|
-
output: string,
|
|
599
|
-
repoRoot: string,
|
|
600
|
-
ignorePaths?: string[],
|
|
601
|
-
): GitSubmoduleStatus[] {
|
|
602
|
-
const submodules: GitSubmoduleStatus[] = [];
|
|
603
|
-
const ignoreSet = new Set(ignorePaths || []);
|
|
604
|
-
|
|
605
|
-
for (const line of output.split('\n')) {
|
|
606
|
-
if (!line.trim()) continue;
|
|
607
|
-
|
|
608
|
-
// Format: [+-U ]<commit> <path> (<branch>)
|
|
609
|
-
// - = not initialized, + = gitlink out of sync, U = conflict, ' ' = aligned.
|
|
610
|
-
const match = line.match(/^([\-+U\s])([0-9a-f]{40})\s+(\S+)(?:\s+\(([^)]+)\))?/);
|
|
611
|
-
if (!match) continue;
|
|
612
|
-
|
|
613
|
-
const prefix = match[1];
|
|
614
|
-
const commit = match[2];
|
|
615
|
-
const path = match[3];
|
|
616
|
-
|
|
617
|
-
if (ignoreSet.has(path)) continue;
|
|
618
|
-
|
|
619
|
-
submodules.push({
|
|
620
|
-
path,
|
|
621
|
-
commit,
|
|
622
|
-
repoPath: repoRoot + '/' + path,
|
|
623
|
-
dirty: prefix === 'U',
|
|
624
|
-
outOfSync: prefix === '-' || prefix === '+',
|
|
625
|
-
lastCheckedAt: Date.now(),
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
return submodules;
|
|
630
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,8 @@ export type {
|
|
|
135
135
|
RepoMeshLedgerStatus,
|
|
136
136
|
MeshAsyncJobLifecycle,
|
|
137
137
|
RepoMeshSchedulingStrategy,
|
|
138
|
+
RepoMeshTaskAffinityPolicy,
|
|
139
|
+
StandardMeshRole,
|
|
138
140
|
} from './repo-mesh-types.js';
|
|
139
141
|
export {
|
|
140
142
|
DEFAULT_MESH_POLICY,
|
|
@@ -146,6 +148,10 @@ export {
|
|
|
146
148
|
MESH_CONVERGE_REFINE_TAG,
|
|
147
149
|
MESH_CONVERGE_FAST_FORWARD_TAG,
|
|
148
150
|
resolveAutoConvergeCodeChange,
|
|
151
|
+
STANDARD_MESH_ROLES,
|
|
152
|
+
DEFAULT_TASKMODE_ROLE_MAP,
|
|
153
|
+
resolveTaskAffinityRole,
|
|
154
|
+
resolveMeshRoleOptions,
|
|
149
155
|
} from './repo-mesh-types.js';
|
|
150
156
|
|
|
151
157
|
// ── Git Surface ──
|
|
@@ -231,7 +237,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
|
|
|
231
237
|
export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
|
|
232
238
|
|
|
233
239
|
// ── Mesh Work Queue (GUPP) ──
|
|
234
|
-
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
240
|
+
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, resolveTaskAffinityRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
235
241
|
export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
|
|
236
242
|
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
|
|
237
243
|
export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
|
|
@@ -1027,7 +1027,11 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
|
|
|
1027
1027
|
|
|
1028
1028
|
const remoteCandidates: IdleCandidate[] = [];
|
|
1029
1029
|
for (const idle of remoteSessions) {
|
|
1030
|
-
|
|
1030
|
+
// Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
|
|
1031
|
+
// `n.id`, so an inline-cached worktree node whose identity arrived under a
|
|
1032
|
+
// different form is not silently dropped — leaving a remote idle session
|
|
1033
|
+
// unable to claim its pending queue task.
|
|
1034
|
+
const node = mesh.nodes.find((n: any) => meshNodeIdMatches(n, idle.nodeId));
|
|
1031
1035
|
if (node) {
|
|
1032
1036
|
remoteIdleSessionsChecked += 1;
|
|
1033
1037
|
remoteCandidates.push({ nodeId: idle.nodeId, sessionId: idle.sessionId, providerType: idle.providerType, origin: 'remote', node });
|