@adhdev/daemon-core 1.0.18-rc.9 → 1.0.19
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-adapters/provider-cli-shared.d.ts +33 -0
- package/dist/commands/cli-manager.d.ts +9 -0
- package/dist/commands/upgrade-helper.d.ts +1 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
- package/dist/config/mesh-json-config.d.ts +62 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2205 -657
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2173 -634
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-node-identity.d.ts +23 -0
- package/dist/mesh/mesh-refine-gates.d.ts +89 -0
- package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
- package/dist/mesh/mesh-work-queue.d.ts +24 -1
- package/dist/providers/auto-approve-modes.d.ts +14 -0
- package/dist/providers/chat-message-normalization.d.ts +22 -0
- package/dist/providers/cli-provider-instance-types.d.ts +2 -0
- package/dist/providers/cli-provider-instance.d.ts +76 -0
- package/dist/providers/contracts.d.ts +17 -0
- package/dist/providers/provider-schema.d.ts +1 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
- package/dist/providers/types/interactive-prompt.d.ts +18 -0
- package/dist/repo-mesh-types.d.ts +38 -6
- package/dist/shared-types.d.ts +4 -2
- package/package.json +3 -3
- package/src/cli-adapters/cli-state-engine.ts +17 -1
- package/src/cli-adapters/provider-cli-adapter.ts +2 -6
- package/src/cli-adapters/provider-cli-shared.ts +48 -0
- package/src/commands/cli-manager.ts +72 -8
- package/src/commands/high-family/mesh-events.ts +13 -2
- package/src/commands/med-family/mesh-crud.ts +155 -0
- package/src/commands/med-family/mesh-queue.ts +74 -1
- package/src/commands/router-refine.ts +71 -4
- package/src/commands/router.ts +8 -0
- package/src/commands/upgrade-helper.ts +50 -1
- package/src/commands/windows-atomic-upgrade.ts +88 -23
- package/src/config/mesh-json-config.ts +103 -0
- package/src/index.ts +21 -1
- package/src/mesh/coordinator-prompt.ts +2 -1
- package/src/mesh/mesh-active-work.ts +11 -1
- package/src/mesh/mesh-completion-synthesis.ts +12 -1
- package/src/mesh/mesh-event-classify.ts +19 -0
- package/src/mesh/mesh-event-forwarding.ts +64 -6
- package/src/mesh/mesh-events-utils.ts +42 -0
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-node-identity.ts +49 -0
- package/src/mesh/mesh-queue-assignment.ts +84 -10
- package/src/mesh/mesh-reconcile-loop.ts +257 -3
- package/src/mesh/mesh-refine-gates.ts +300 -0
- package/src/mesh/mesh-remote-event-pull.ts +68 -47
- package/src/mesh/mesh-work-queue.ts +85 -2
- package/src/providers/auto-approve-modes.ts +97 -0
- package/src/providers/chat-message-normalization.ts +53 -0
- package/src/providers/cli-provider-instance-types.ts +28 -0
- package/src/providers/cli-provider-instance.ts +394 -28
- package/src/providers/contracts.ts +25 -1
- package/src/providers/provider-schema.ts +83 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
- package/src/providers/types/interactive-prompt.ts +77 -0
- package/src/repo-mesh-types.ts +112 -12
- package/src/shared-types.ts +9 -1
- package/src/status/reporter.ts +1 -0
- package/src/status/snapshot.ts +2 -0
|
@@ -254,6 +254,54 @@ export interface CliProviderModule {
|
|
|
254
254
|
_versionWarning?: string | null;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* PURE-PTY TRANSCRIPT CLASS predicate (kimi and kin).
|
|
259
|
+
*
|
|
260
|
+
* A provider is "pure-PTY full-buffer" when it reconstructs its ENTIRE transcript
|
|
261
|
+
* from the rendered PTY buffer on every read and has NO alternate transcript
|
|
262
|
+
* source:
|
|
263
|
+
* - transcriptAuthority !== 'provider' (the daemon PTY parser owns the transcript,
|
|
264
|
+
* not a provider-side canonical source)
|
|
265
|
+
* - NO nativeHistory (no on-disk / native-source history to fall back to)
|
|
266
|
+
* - tui.transcriptPty.scope === 'buffer' (the parser walks the full rendered buffer)
|
|
267
|
+
*
|
|
268
|
+
* This class is invisible to two provider-authority-keyed code paths that other
|
|
269
|
+
* providers rely on for mesh completion semantics:
|
|
270
|
+
* 1. CliStateEngine.onTurnStarted only promotes to 'generating' for
|
|
271
|
+
* transcriptAuthority==='provider' providers — so a pure-PTY session that
|
|
272
|
+
* submits a prompt while already idle collapses idle→idle, the
|
|
273
|
+
* generating→idle edge never occurs, and agent:generating_completed is never
|
|
274
|
+
* emitted (the coordinator ledger leaves the task 'assigned' forever).
|
|
275
|
+
* 2. checkMeshWorkerStall's native-transcript completion reconcile is gated on
|
|
276
|
+
* the native-source shape, so a finished pure-PTY worker's static idle is
|
|
277
|
+
* misread as monitor:no_progress (a false task_stalled).
|
|
278
|
+
*
|
|
279
|
+
* The runtime capability, NOT any single spec field value, is authoritative: a
|
|
280
|
+
* given kimi manifest checkout may declare nativeHistory/transcriptAuthority, but
|
|
281
|
+
* the live-loaded pure-PTY session has none. Callers that only hold a
|
|
282
|
+
* CliProviderModule (the engine) share this exact predicate with the adapter
|
|
283
|
+
* (parsesFullPtyTranscriptFromBuffer) so the two never drift.
|
|
284
|
+
*/
|
|
285
|
+
export function isPurePtyTranscriptProvider(provider: {
|
|
286
|
+
transcriptAuthority?: 'provider' | 'daemon';
|
|
287
|
+
nativeHistory?: unknown;
|
|
288
|
+
tui?: Record<string, unknown>;
|
|
289
|
+
} | null | undefined): boolean {
|
|
290
|
+
// No provider (e.g. an instance whose module isn't set when the stall watchdog
|
|
291
|
+
// ticks) ⇒ not the pure-PTY class. Guarding here protects every caller — the
|
|
292
|
+
// stall-reconcile and turn-start paths pass this.provider unguarded, and
|
|
293
|
+
// this.provider is genuinely nullable at runtime (see this.provider?.nativeHistory
|
|
294
|
+
// in cli-provider-instance.ts). Matches resolveLocalSessionPurePty's own
|
|
295
|
+
// no-provider ⇒ not-pure-PTY contract.
|
|
296
|
+
if (!provider) return false;
|
|
297
|
+
if (provider.transcriptAuthority === 'provider') return false;
|
|
298
|
+
// nativeHistory is a top-level provider field not surfaced on
|
|
299
|
+
// CliProviderModule; read it via the same structural shape the adapter uses.
|
|
300
|
+
if (provider.nativeHistory) return false;
|
|
301
|
+
const transcriptPty = (provider.tui as { transcriptPty?: { scope?: unknown } } | undefined)?.transcriptPty;
|
|
302
|
+
return transcriptPty?.scope === 'buffer';
|
|
303
|
+
}
|
|
304
|
+
|
|
257
305
|
function stripAnsi(str: string): string {
|
|
258
306
|
// eslint-disable-next-line no-control-regex
|
|
259
307
|
return str
|
|
@@ -34,6 +34,7 @@ import type { SessionRegistry } from '../sessions/registry.js';
|
|
|
34
34
|
import type { ProviderInstance } from '../providers/provider-instance.js';
|
|
35
35
|
import { LOG } from '../logging/logger.js';
|
|
36
36
|
import { shouldRestoreHostedRuntime } from './hosted-runtime-restore.js';
|
|
37
|
+
import { findProviderAutoApproveMode, resolveProviderAutoApproveMode } from '../providers/auto-approve-modes.js';
|
|
37
38
|
|
|
38
39
|
// ─── external dependency interface ──────────────────────────
|
|
39
40
|
|
|
@@ -452,6 +453,40 @@ export function expandThinkingLaunchArgs(
|
|
|
452
453
|
return template.map((part) => part.includes('{{level}}') ? part.replace('{{level}}', mapped) : part);
|
|
453
454
|
}
|
|
454
455
|
|
|
456
|
+
function matchesRemovedLaunchArg(arg: string, removeArg: string): boolean {
|
|
457
|
+
return arg === removeArg || (removeArg.startsWith('--') && arg.startsWith(`${removeArg}=`));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Apply a selected launch-args auto-approve mode without mutating provider metadata.
|
|
462
|
+
* removeArgs only targets provider-owned base spawn.args; launchArgs are prepended to
|
|
463
|
+
* per-launch args beside model/thinking args, making conflict removal order-independent.
|
|
464
|
+
*/
|
|
465
|
+
export function applyAutoApproveModeLaunchArgs(
|
|
466
|
+
provider: ProviderModule | undefined,
|
|
467
|
+
cliArgs: string[] | undefined,
|
|
468
|
+
settings: Record<string, unknown> | undefined,
|
|
469
|
+
): { provider: ProviderModule | undefined; cliArgs: string[] | undefined } {
|
|
470
|
+
if (!provider) return { provider, cliArgs };
|
|
471
|
+
const resolved = resolveProviderAutoApproveMode(provider, settings);
|
|
472
|
+
if (!resolved.active || resolved.strategy !== 'launch-args') return { provider, cliArgs };
|
|
473
|
+
const mode = findProviderAutoApproveMode(provider, resolved.modeId);
|
|
474
|
+
if (!mode || !Array.isArray(mode.launchArgs) || mode.launchArgs.length === 0) return { provider, cliArgs };
|
|
475
|
+
|
|
476
|
+
const removeArgs = Array.isArray(mode.removeArgs) ? mode.removeArgs : [];
|
|
477
|
+
const baseArgs = provider.spawn?.args;
|
|
478
|
+
const filteredBaseArgs = Array.isArray(baseArgs) && removeArgs.length > 0
|
|
479
|
+
? baseArgs.filter((arg) => !removeArgs.some((removeArg) => matchesRemovedLaunchArg(arg, removeArg)))
|
|
480
|
+
: baseArgs;
|
|
481
|
+
const launchProvider = filteredBaseArgs === baseArgs
|
|
482
|
+
? provider
|
|
483
|
+
: { ...provider, spawn: { ...provider.spawn!, args: filteredBaseArgs } };
|
|
484
|
+
return {
|
|
485
|
+
provider: launchProvider,
|
|
486
|
+
cliArgs: [...mode.launchArgs, ...(cliArgs || [])],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
455
490
|
function readSubcommandSessionId(args: string[], subcommands: string[]): string | undefined {
|
|
456
491
|
const resumeIndex = args.findIndex((arg) => subcommands.includes(arg));
|
|
457
492
|
if (resumeIndex < 0) return undefined;
|
|
@@ -707,6 +742,24 @@ export class DaemonCliManager {
|
|
|
707
742
|
clearInterval(checkStopped);
|
|
708
743
|
setTimeout(() => {
|
|
709
744
|
if (this.adapters.has(key)) {
|
|
745
|
+
// KIMI-MESH-COMPLETION-EMIT (axis 2): before removeInstance closes the
|
|
746
|
+
// event-emit window, give a mesh DELEGATED worker one last chance to emit
|
|
747
|
+
// its completion. A native-source worker (e.g. kimi) can have its PTY
|
|
748
|
+
// killed by a false stall AFTER it finished the task (transcript written)
|
|
749
|
+
// but BEFORE the FSM's idle→completed event fired — the instance is the
|
|
750
|
+
// only thing that can emit that event, and it is about to be removed. The
|
|
751
|
+
// instance-side method is a no-op for a non-mesh session or when the
|
|
752
|
+
// turn's completion already fired (double-emit guard) or when there is no
|
|
753
|
+
// transcript evidence of a finished turn. Best-effort — never blocks cleanup.
|
|
754
|
+
try {
|
|
755
|
+
const inst = instanceManager?.getInstance(key) as (ProviderInstance & { flushMeshCompletionBeforeCleanup?: () => boolean }) | undefined;
|
|
756
|
+
if (typeof inst?.flushMeshCompletionBeforeCleanup === 'function') {
|
|
757
|
+
const emitted = inst.flushMeshCompletionBeforeCleanup();
|
|
758
|
+
if (emitted) LOG.info('CLI', `Emitted pre-cleanup mesh completion for ${cliType} session ${key} before auto-clean`);
|
|
759
|
+
}
|
|
760
|
+
} catch (e) {
|
|
761
|
+
LOG.warn('CLI', `pre-cleanup mesh completion flush failed for ${key}: ${(e as Error)?.message || e}`);
|
|
762
|
+
}
|
|
710
763
|
this.adapters.delete(key);
|
|
711
764
|
this.deps.removeAgentTracking(key);
|
|
712
765
|
sessionRegistry?.unregisterByInstanceKey(key);
|
|
@@ -1030,6 +1083,17 @@ export class DaemonCliManager {
|
|
|
1030
1083
|
console.log(colorize('cyan', ` 📦 Using provider: ${provider.name} (${provider.type})`));
|
|
1031
1084
|
}
|
|
1032
1085
|
|
|
1086
|
+
const launchSettings = {
|
|
1087
|
+
...this.providerLoader.getSettings(normalizedType),
|
|
1088
|
+
...(options?.settingsOverride || {}),
|
|
1089
|
+
};
|
|
1090
|
+
const versionResolvedProvider = provider
|
|
1091
|
+
? (this.providerLoader.resolve(cliType, { version: cliInfo.version }) || provider)
|
|
1092
|
+
: undefined;
|
|
1093
|
+
const autoApproveLaunch = applyAutoApproveModeLaunchArgs(versionResolvedProvider, cliArgs, launchSettings);
|
|
1094
|
+
const launchProvider = autoApproveLaunch.provider || provider;
|
|
1095
|
+
const cliArgsWithAutoApprove = autoApproveLaunch.cliArgs;
|
|
1096
|
+
|
|
1033
1097
|
// ─── Model axis (MAGI kind-panel): expand initialModel → launch args ───
|
|
1034
1098
|
// For a plain CLI provider the model is selected at spawn time via the manifest's
|
|
1035
1099
|
// modelLaunchArgs template ('{{model}}' → the requested model). ACP providers took
|
|
@@ -1037,10 +1101,10 @@ export class DaemonCliManager {
|
|
|
1037
1101
|
// or no requested model, is a no-op — model selection is best-effort and must never
|
|
1038
1102
|
// fail a launch. The model args are prepended so a caller's explicit cliArgs (e.g. a
|
|
1039
1103
|
// resume flag) still win positionally where order matters.
|
|
1040
|
-
const modelLaunchArgs = expandModelLaunchArgs(
|
|
1104
|
+
const modelLaunchArgs = expandModelLaunchArgs(launchProvider?.modelLaunchArgs, initialModel);
|
|
1041
1105
|
const cliArgsWithModel = modelLaunchArgs
|
|
1042
|
-
? [...modelLaunchArgs, ...(
|
|
1043
|
-
:
|
|
1106
|
+
? [...modelLaunchArgs, ...(cliArgsWithAutoApprove || [])]
|
|
1107
|
+
: cliArgsWithAutoApprove;
|
|
1044
1108
|
if (initialModel && !modelLaunchArgs) {
|
|
1045
1109
|
LOG.warn('CLI', `[${normalizedType}] initialModel='${initialModel}' requested but provider declares no modelLaunchArgs template — launching without model selection.`);
|
|
1046
1110
|
}
|
|
@@ -1051,7 +1115,7 @@ export class DaemonCliManager {
|
|
|
1051
1115
|
// Best-effort; a provider with no template (or no requested level) is a no-op. ACP
|
|
1052
1116
|
// providers route thinking through setConfigOption('thought_level') above.
|
|
1053
1117
|
const initialThinkingLevel = options?.initialThinkingLevel;
|
|
1054
|
-
const thinkingLaunchArgs = expandThinkingLaunchArgs(
|
|
1118
|
+
const thinkingLaunchArgs = expandThinkingLaunchArgs(launchProvider?.thinkingLaunchArgs, initialThinkingLevel, launchProvider?.thinkingLevelMap);
|
|
1055
1119
|
const cliArgsWithBrain = thinkingLaunchArgs
|
|
1056
1120
|
? [...thinkingLaunchArgs, ...(cliArgsWithModel || [])]
|
|
1057
1121
|
: cliArgsWithModel;
|
|
@@ -1060,13 +1124,13 @@ export class DaemonCliManager {
|
|
|
1060
1124
|
}
|
|
1061
1125
|
|
|
1062
1126
|
// ─── Resolve launch options → provider session binding ───
|
|
1063
|
-
const sessionBinding = resolveCliSessionBinding(
|
|
1127
|
+
const sessionBinding = resolveCliSessionBinding(launchProvider, normalizedType, cliArgsWithBrain, options?.resumeSessionId);
|
|
1064
1128
|
const resolvedCliArgs = sessionBinding.cliArgs;
|
|
1065
1129
|
|
|
1066
1130
|
// If InstanceManager exists, manage as CliProviderInstance unified
|
|
1067
1131
|
const instanceManager = this.deps.getInstanceManager();
|
|
1068
|
-
if (
|
|
1069
|
-
const resolvedProvider =
|
|
1132
|
+
if (launchProvider && instanceManager) {
|
|
1133
|
+
const resolvedProvider = launchProvider;
|
|
1070
1134
|
await this.registerCliInstance(
|
|
1071
1135
|
key,
|
|
1072
1136
|
normalizedType,
|
|
@@ -1074,7 +1138,7 @@ export class DaemonCliManager {
|
|
|
1074
1138
|
resolvedDir,
|
|
1075
1139
|
resolvedCliArgs,
|
|
1076
1140
|
resolvedProvider,
|
|
1077
|
-
|
|
1141
|
+
launchSettings,
|
|
1078
1142
|
false,
|
|
1079
1143
|
{
|
|
1080
1144
|
providerSessionId: sessionBinding.providerSessionId,
|
|
@@ -93,10 +93,21 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
|
|
|
93
93
|
? args.sessionId.trim()
|
|
94
94
|
: '';
|
|
95
95
|
if (!sessionId) return { success: false, error: 'targetSessionId required' };
|
|
96
|
-
const
|
|
96
|
+
const rawResponse = args?.response ?? args;
|
|
97
97
|
const instance = ctx.deps.instanceManager.getInstance(sessionId);
|
|
98
98
|
if (!instance) return { success: false, error: `No running instance for session ${sessionId}` };
|
|
99
|
-
|
|
99
|
+
// mesh_answer_question (mission f1d25e11) sends a coordinator-friendly answer array
|
|
100
|
+
// that is resolved against the AUTHORITATIVE active prompt inside the instance
|
|
101
|
+
// (resolveInteractivePromptResponse). Forward that shape RAW. The legacy strict
|
|
102
|
+
// (questionId-keyed) form is still validated here so a malformed dashboard-local
|
|
103
|
+
// answer is rejected before it reaches the instance.
|
|
104
|
+
const isFriendlyArrayForm = rawResponse
|
|
105
|
+
&& typeof rawResponse === 'object'
|
|
106
|
+
&& Array.isArray((rawResponse as { answers?: unknown }).answers);
|
|
107
|
+
const payload = isFriendlyArrayForm
|
|
108
|
+
? rawResponse
|
|
109
|
+
: normalizeInteractivePromptResponse(rawResponse);
|
|
110
|
+
ctx.deps.instanceManager.sendEvent(sessionId, 'interactive_prompt_response', payload);
|
|
100
111
|
return { success: true };
|
|
101
112
|
},
|
|
102
113
|
};
|
|
@@ -399,6 +399,161 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
399
399
|
}
|
|
400
400
|
},
|
|
401
401
|
|
|
402
|
+
// READ path for `.adhdev/mesh.json` — returns the currently-committed repo
|
|
403
|
+
// config (parsed + normalized) for a workspace so a UI can render/edit the
|
|
404
|
+
// existing declarative zones (notably `providerDefaults.autoApproveModes`)
|
|
405
|
+
// WITHOUT re-deriving them from the machine-local scaffold. Never writes.
|
|
406
|
+
// `config` is undefined when no repo file exists (sourceType 'unavailable') or
|
|
407
|
+
// it is unparseable (sourceType 'invalid', with the parse error surfaced).
|
|
408
|
+
read_mesh_json_config: async (_ctx: MedFamilyContext, args: any) => {
|
|
409
|
+
const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
|
|
410
|
+
try {
|
|
411
|
+
const { loadRepoMeshJsonConfig } = await import('../../config/mesh-json-config.js');
|
|
412
|
+
const loaded = loadRepoMeshJsonConfig(workspace);
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
workspace,
|
|
416
|
+
sourceType: loaded.sourceType,
|
|
417
|
+
source: loaded.source,
|
|
418
|
+
...(loaded.path ? { path: loaded.path } : {}),
|
|
419
|
+
...(loaded.error ? { error: loaded.error } : {}),
|
|
420
|
+
config: loaded.config,
|
|
421
|
+
// Convenience projection so the UI does not have to reach into config.
|
|
422
|
+
providerDefaults: loaded.config?.providerDefaults,
|
|
423
|
+
};
|
|
424
|
+
} catch (e: any) {
|
|
425
|
+
return { success: false, error: e.message };
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
// Partial-edit WRITE path for `.adhdev/mesh.json` `providerDefaults` — a
|
|
430
|
+
// READ-MODIFY-WRITE that preserves operator hand-edits. Unlike
|
|
431
|
+
// write_mesh_json_config (which rebuilds the WHOLE file from the machine-local
|
|
432
|
+
// scaffold and can silently drop hand-edited zones), this parses the existing
|
|
433
|
+
// repo file, merges ONLY the providerDefaults.autoApproveModes zone, and
|
|
434
|
+
// re-serializes — coordinator prompt, operating notes and limits authored in
|
|
435
|
+
// the repo are carried through untouched. Defaults to dry-run.
|
|
436
|
+
//
|
|
437
|
+
// args: { workspace?, autoApproveModes: Record<providerType,modeId>, write?, merge? }
|
|
438
|
+
// merge=true (default): per-provider merge into the existing map; a modeId of
|
|
439
|
+
// '' | null removes that provider's entry. merge=false: REPLACE the whole
|
|
440
|
+
// autoApproveModes map with the supplied one.
|
|
441
|
+
set_mesh_provider_defaults: async (_ctx: MedFamilyContext, args: any) => {
|
|
442
|
+
const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
|
|
443
|
+
const write = args?.write === true;
|
|
444
|
+
const merge = args?.merge !== false; // default true
|
|
445
|
+
const inputModes = args?.autoApproveModes;
|
|
446
|
+
if (inputModes !== undefined && (typeof inputModes !== 'object' || inputModes === null || Array.isArray(inputModes))) {
|
|
447
|
+
return { success: false, error: 'autoApproveModes must be an object (providerType → modeId) when provided' };
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const {
|
|
451
|
+
loadRepoMeshJsonConfig,
|
|
452
|
+
normalizeRepoMeshDeclarativeConfig,
|
|
453
|
+
MESH_JSON_CONFIG_LOCATIONS,
|
|
454
|
+
} = await import('../../config/mesh-json-config.js');
|
|
455
|
+
const { existsSync, readFileSync, mkdirSync, writeFileSync } = await import('fs');
|
|
456
|
+
const { dirname, join } = await import('path');
|
|
457
|
+
const yaml = await import('js-yaml');
|
|
458
|
+
|
|
459
|
+
const relativePath = MESH_JSON_CONFIG_LOCATIONS[0];
|
|
460
|
+
|
|
461
|
+
// Read-modify-write: parse the EXISTING on-disk document (preferring the
|
|
462
|
+
// first existing json/yaml variant) so unrelated zones survive verbatim.
|
|
463
|
+
let baseDoc: Record<string, any> = { version: 1 };
|
|
464
|
+
let existingPath = join(workspace, relativePath);
|
|
465
|
+
let existedAsYaml = false;
|
|
466
|
+
for (const relative of MESH_JSON_CONFIG_LOCATIONS) {
|
|
467
|
+
const candidate = join(workspace, relative);
|
|
468
|
+
if (!existsSync(candidate)) continue;
|
|
469
|
+
try {
|
|
470
|
+
const text = readFileSync(candidate, 'utf-8');
|
|
471
|
+
const parsed = /\.json$/i.test(candidate) ? JSON.parse(text) : yaml.load(text);
|
|
472
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
473
|
+
baseDoc = parsed as Record<string, any>;
|
|
474
|
+
existingPath = candidate;
|
|
475
|
+
existedAsYaml = !/\.json$/i.test(candidate);
|
|
476
|
+
}
|
|
477
|
+
} catch (e: any) {
|
|
478
|
+
return { success: false, error: `existing ${relative} is unparseable, refusing to overwrite: ${e?.message || e}` };
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Merge ONLY the providerDefaults.autoApproveModes zone.
|
|
484
|
+
const existingPd = baseDoc.providerDefaults && typeof baseDoc.providerDefaults === 'object' && !Array.isArray(baseDoc.providerDefaults)
|
|
485
|
+
? baseDoc.providerDefaults as Record<string, any>
|
|
486
|
+
: {};
|
|
487
|
+
const existingModes = existingPd.autoApproveModes && typeof existingPd.autoApproveModes === 'object' && !Array.isArray(existingPd.autoApproveModes)
|
|
488
|
+
? { ...existingPd.autoApproveModes as Record<string, string> }
|
|
489
|
+
: {};
|
|
490
|
+
|
|
491
|
+
const nextModes: Record<string, string> = merge ? existingModes : {};
|
|
492
|
+
if (inputModes) {
|
|
493
|
+
for (const [providerType, modeId] of Object.entries(inputModes as Record<string, unknown>)) {
|
|
494
|
+
const type = typeof providerType === 'string' ? providerType.trim() : '';
|
|
495
|
+
if (!type) continue;
|
|
496
|
+
const id = typeof modeId === 'string' ? modeId.trim() : '';
|
|
497
|
+
if (id) nextModes[type] = id;
|
|
498
|
+
else delete nextModes[type]; // '' / null → remove this provider's entry
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const nextDoc: Record<string, any> = { ...baseDoc, version: 1 };
|
|
503
|
+
if (Object.keys(nextModes).length) {
|
|
504
|
+
nextDoc.providerDefaults = { ...existingPd, autoApproveModes: nextModes };
|
|
505
|
+
} else {
|
|
506
|
+
// No entries left → drop the zone entirely so we don't leave an empty stub.
|
|
507
|
+
if (nextDoc.providerDefaults) {
|
|
508
|
+
const { autoApproveModes, ...restPd } = nextDoc.providerDefaults;
|
|
509
|
+
if (Object.keys(restPd).length) nextDoc.providerDefaults = restPd;
|
|
510
|
+
else delete nextDoc.providerDefaults;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Validate the merged document before it ever touches disk.
|
|
515
|
+
const validation = normalizeRepoMeshDeclarativeConfig(nextDoc);
|
|
516
|
+
if (!validation.valid) {
|
|
517
|
+
return { success: false, error: `merged mesh.json is invalid: ${validation.errors.join('; ')}` };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Serialize in the on-disk format (JSON unless the existing file was YAML).
|
|
521
|
+
const absolutePath = existingPath;
|
|
522
|
+
const serialized = existedAsYaml
|
|
523
|
+
? yaml.dump(nextDoc, { indent: 2 })
|
|
524
|
+
: `${JSON.stringify(nextDoc, null, 2)}\n`;
|
|
525
|
+
|
|
526
|
+
if (!write) {
|
|
527
|
+
return {
|
|
528
|
+
success: true,
|
|
529
|
+
written: false,
|
|
530
|
+
dryRun: true,
|
|
531
|
+
path: absolutePath,
|
|
532
|
+
relativePath,
|
|
533
|
+
merge,
|
|
534
|
+
providerDefaults: nextDoc.providerDefaults,
|
|
535
|
+
preview: serialized,
|
|
536
|
+
note: 'Dry-run: nothing written. Re-run with write=true to persist. Only the providerDefaults zone is merged; other repo zones are preserved.',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
541
|
+
writeFileSync(absolutePath, serialized, 'utf-8');
|
|
542
|
+
return {
|
|
543
|
+
success: true,
|
|
544
|
+
written: true,
|
|
545
|
+
dryRun: false,
|
|
546
|
+
path: absolutePath,
|
|
547
|
+
relativePath,
|
|
548
|
+
merge,
|
|
549
|
+
providerDefaults: nextDoc.providerDefaults,
|
|
550
|
+
note: 'Wrote providerDefaults into .adhdev/mesh.json (read-modify-write; other zones preserved). Commit it to the repo.',
|
|
551
|
+
};
|
|
552
|
+
} catch (e: any) {
|
|
553
|
+
return { success: false, error: e.message };
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
|
|
402
557
|
delete_mesh: async (_ctx: MedFamilyContext, args: any) => {
|
|
403
558
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
404
559
|
if (!meshId) return { success: false, error: 'meshId required' };
|
|
@@ -9,6 +9,67 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { readStringValue } from '../router.js';
|
|
11
11
|
import type { MedFamilyContext, MedFamilyHandler } from './types.js';
|
|
12
|
+
import { LOG } from '../../logging/logger.js';
|
|
13
|
+
import type { CancelledTaskAssignment } from '../../mesh/mesh-work-queue.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* CANCEL-STICKY-TERMINAL (authoritative cancel): stop the worker a just-cancelled task was
|
|
17
|
+
* bound to. Mirrors the transport-aware stop mesh-event-forwarding's stopStaleMeshWorker
|
|
18
|
+
* performs for reclaimed workers, but runs from the command layer using ctx.deps (which the
|
|
19
|
+
* pure queue-store module lacks): local adapter → stop_cli directly; otherwise resolve the
|
|
20
|
+
* worker node's daemon id from mesh config and dispatch stop_cli over P2P/relay. Fully
|
|
21
|
+
* best-effort — a failed stop only loses the belt-and-suspenders; the cancelled row is already
|
|
22
|
+
* terminal and cannot be resurrected (updateTaskStatus terminal guard).
|
|
23
|
+
*/
|
|
24
|
+
async function stopCancelledTaskWorker(
|
|
25
|
+
ctx: MedFamilyContext,
|
|
26
|
+
meshId: string,
|
|
27
|
+
prior: CancelledTaskAssignment,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const { sessionId, nodeId, providerType } = prior;
|
|
30
|
+
const stopArgs: Record<string, unknown> = {
|
|
31
|
+
targetSessionId: sessionId,
|
|
32
|
+
...(providerType ? { cliType: providerType } : {}),
|
|
33
|
+
mode: 'hard',
|
|
34
|
+
reason: 'mesh_task_cancelled',
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const cliManager = ctx.deps.cliManager as any;
|
|
38
|
+
const isLocal = cliManager?.adapters?.has?.(sessionId) === true;
|
|
39
|
+
if (isLocal) {
|
|
40
|
+
if (!stopArgs.cliType) {
|
|
41
|
+
const localType = cliManager?.adapters?.get?.(sessionId)?.cliType;
|
|
42
|
+
if (localType) stopArgs.cliType = localType;
|
|
43
|
+
}
|
|
44
|
+
await Promise.resolve(cliManager?.handleCliCommand?.('stop_cli', stopArgs))
|
|
45
|
+
.catch((e: any) => LOG.warn('MeshQueue', `Local stop of cancelled worker ${sessionId} failed: ${e?.message || e}`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Remote: resolve the worker node's daemon id from mesh config, then dispatch stop_cli.
|
|
49
|
+
const dispatch = ctx.deps.dispatchMeshCommand;
|
|
50
|
+
let daemonId: string | undefined;
|
|
51
|
+
if (nodeId) {
|
|
52
|
+
try {
|
|
53
|
+
const [{ getMesh }, { meshNodeIdMatches }, { readMeshNodeDaemonId }] = await Promise.all([
|
|
54
|
+
import('../../config/mesh-config.js'),
|
|
55
|
+
import('@adhdev/mesh-shared'),
|
|
56
|
+
import('../../mesh/mesh-node-identity.js'),
|
|
57
|
+
]);
|
|
58
|
+
const mesh = getMesh(meshId) as { nodes?: any[] } | undefined;
|
|
59
|
+
const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
60
|
+
daemonId = node ? readMeshNodeDaemonId(node) || undefined : undefined;
|
|
61
|
+
} catch { /* best-effort resolution */ }
|
|
62
|
+
}
|
|
63
|
+
if (daemonId && dispatch) {
|
|
64
|
+
await Promise.resolve(dispatch(daemonId, 'stop_cli', stopArgs))
|
|
65
|
+
.catch((e: any) => LOG.warn('MeshQueue', `Remote stop of cancelled worker ${sessionId} on daemon ${daemonId} failed: ${e?.message || e}`));
|
|
66
|
+
} else {
|
|
67
|
+
LOG.warn('MeshQueue', `Cannot stop cancelled worker ${sessionId}: no local adapter and no resolvable remote daemon id (node ${nodeId ?? '?'}). Row is already terminal; if the worker completes it strand-fails harmlessly.`);
|
|
68
|
+
}
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
LOG.warn('MeshQueue', `stopCancelledTaskWorker error for ${sessionId}: ${e?.message || e}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
12
73
|
|
|
13
74
|
export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
|
|
14
75
|
get_mesh_queue: async (_ctx: MedFamilyContext, args: any) => {
|
|
@@ -50,10 +111,22 @@ export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
|
|
|
50
111
|
const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'queue cancellation');
|
|
51
112
|
if (ownerFailure) return ownerFailure;
|
|
52
113
|
try {
|
|
53
|
-
const { cancelTask } = await import('../../mesh/mesh-work-queue.js');
|
|
114
|
+
const { cancelTask, takeCancelledTaskAssignment } = await import('../../mesh/mesh-work-queue.js');
|
|
54
115
|
const reason = typeof args?.reason === 'string' ? args.reason : undefined;
|
|
55
116
|
const task = cancelTask(meshId, taskId, { reason });
|
|
56
117
|
if (!task) return { success: false, error: `Queue task '${taskId}' not found` };
|
|
118
|
+
// CANCEL-STICKY-TERMINAL (authoritative cancel): stop the worker that was bound to the
|
|
119
|
+
// now-cancelled task. cancelTask already cleared the queue's assignment fields (so the
|
|
120
|
+
// reclaim watchdog no longer sees it as a live assignment), but a still-running worker
|
|
121
|
+
// keeps emitting delivery/turn signals that re-ignite reclaim. cancelTask runs in the
|
|
122
|
+
// pure queue-store module (no DaemonComponents), so the transport-aware stop is done
|
|
123
|
+
// here where ctx.deps holds cliManager / dispatchMeshCommand. Best-effort: a failed
|
|
124
|
+
// stop only loses the belt-and-suspenders — the row is already terminal and un-revivable
|
|
125
|
+
// thanks to the updateTaskStatus terminal guard.
|
|
126
|
+
const prior = takeCancelledTaskAssignment(meshId, taskId);
|
|
127
|
+
if (prior?.sessionId) {
|
|
128
|
+
void stopCancelledTaskWorker(ctx, meshId, prior);
|
|
129
|
+
}
|
|
57
130
|
return { success: true, task };
|
|
58
131
|
} catch (e: any) {
|
|
59
132
|
return { success: false, error: e.message };
|
|
@@ -36,8 +36,10 @@ import {
|
|
|
36
36
|
RefineExecFileAsync,
|
|
37
37
|
RefineStageOutcome,
|
|
38
38
|
classifyPatchEquivalenceFailure,
|
|
39
|
+
convergeDivergedSubmoduleGitlinks,
|
|
39
40
|
recordMeshRefineStage,
|
|
40
41
|
resolveRefineryAutoPublishSubmoduleMainCommits,
|
|
42
|
+
rootRebaseResolvingGitlinks,
|
|
41
43
|
runMeshRefineEffectiveDiffGate,
|
|
42
44
|
runMeshRefinePatchEquivalenceGate,
|
|
43
45
|
runMeshRefineSubmoduleReachabilityGate,
|
|
@@ -539,6 +541,9 @@ async function computeBranchBaseDivergence(
|
|
|
539
541
|
export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
540
542
|
const { repoRoot, baseHead, node, branch, baseBranch, refineStages, execFileAsync } = ctx;
|
|
541
543
|
let branchHead = ctx.branchHead;
|
|
544
|
+
// Converged submodule gitlink resolutions (path → rebased commit) from STEP 1;
|
|
545
|
+
// consumed by the gitlink-aware root rebase (STEP 2) to resolve gitlink conflicts.
|
|
546
|
+
let gitlinkResolutions: Array<{ path: string; rebasedCommit: string }> = [];
|
|
542
547
|
const syncStarted = Date.now();
|
|
543
548
|
const divergence = await computeBranchBaseDivergence(execFileAsync, node.workspace, baseHead, branchHead);
|
|
544
549
|
|
|
@@ -571,25 +576,87 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
|
|
|
571
576
|
const preRebasePe = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
572
577
|
const alreadyMerged = !preRebasePe.actualPatchId && !!preRebasePe.expectedPatchId;
|
|
573
578
|
const submoduleConflict = preRebasePe.actionableHint?.kind === 'submodule_conflict';
|
|
574
|
-
if (alreadyMerged
|
|
579
|
+
if (alreadyMerged) {
|
|
575
580
|
recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
|
|
576
581
|
ahead: divergence.ahead,
|
|
577
582
|
behind: divergence.behind,
|
|
578
583
|
rebased: false,
|
|
579
|
-
reason:
|
|
584
|
+
reason: 'already_merged_via_other_path_skip_rebase',
|
|
580
585
|
});
|
|
581
586
|
return { kind: 'continue', ctx };
|
|
582
587
|
}
|
|
588
|
+
if (submoduleConflict) {
|
|
589
|
+
// DS3: a "submodule conflict" here means base and branch advanced the
|
|
590
|
+
// SAME submodule to DIVERGED sibling commits (neither an ancestor of the
|
|
591
|
+
// other), so the gitlink stays in the diff and patch-equivalence fails.
|
|
592
|
+
// Attempt to auto-converge it (STEP 1): rebase the branch-side submodule
|
|
593
|
+
// commit onto the base-side commit INSIDE the worktree submodule, so the
|
|
594
|
+
// base-side commit becomes a strict ancestor of the rebased tip. The root
|
|
595
|
+
// rebase below (STEP 2) then resolves the gitlink conflict to that rebased
|
|
596
|
+
// commit. Together this automates the documented manual strict-ff bypass
|
|
597
|
+
// and keeps the landed oss history linear. On any real submodule content
|
|
598
|
+
// conflict it backs out cleanly → we FALL BACK to the historical
|
|
599
|
+
// defer→patch_equivalence path below.
|
|
600
|
+
const converge = convergeDivergedSubmoduleGitlinks(node.workspace, repoRoot, baseHead, branchHead);
|
|
601
|
+
if (converge.converged) {
|
|
602
|
+
gitlinkResolutions = converge.resolutions;
|
|
603
|
+
recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'passed', syncStarted, {
|
|
604
|
+
reason: 'submodule_diverged_auto_rebased',
|
|
605
|
+
gitlinks: converge.gitlinks,
|
|
606
|
+
});
|
|
607
|
+
LOG.info('Mesh', `[Refinery] Auto-converged diverged submodule gitlink(s) onto base for node ${node.id}: `
|
|
608
|
+
+ converge.resolutions.map(r => `${r.path}→${r.rebasedCommit.slice(0, 12)}`).join(', '));
|
|
609
|
+
// Fall through (do NOT return) → the gitlink-aware root rebase below runs.
|
|
610
|
+
} else {
|
|
611
|
+
// Fail-safe: convergence declined (conflict / unreachable / not a real
|
|
612
|
+
// divergence) → preserve the historical defer→blocked_review behavior.
|
|
613
|
+
recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'skipped', syncStarted, {
|
|
614
|
+
reason: 'submodule_conflict_defer_to_patch_equivalence',
|
|
615
|
+
convergeReason: converge.reason,
|
|
616
|
+
gitlinks: converge.gitlinks,
|
|
617
|
+
});
|
|
618
|
+
recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
|
|
619
|
+
ahead: divergence.ahead,
|
|
620
|
+
behind: divergence.behind,
|
|
621
|
+
rebased: false,
|
|
622
|
+
reason: 'submodule_conflict_defer_to_patch_equivalence',
|
|
623
|
+
});
|
|
624
|
+
return { kind: 'continue', ctx };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
583
627
|
} catch { /* fail-open: on gate error, fall through to the rebase */ }
|
|
584
628
|
|
|
585
629
|
// behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
|
|
586
630
|
// baseHead. A conflict aborts and terminates blocked_review (retryable=false —
|
|
587
631
|
// a real content conflict needs human resolution, not a base-movement retry).
|
|
632
|
+
//
|
|
633
|
+
// When STEP 1 converged a diverged submodule gitlink, use the gitlink-aware
|
|
634
|
+
// root rebase (STEP 2): git's recursive merge refuses to auto-merge the still-
|
|
635
|
+
// diverged intermediate gitlink, so we drive the rebase and resolve each
|
|
636
|
+
// submodule-gitlink conflict to the converged commit. A non-gitlink conflict
|
|
637
|
+
// aborts and falls through to the same blocked_review handling as a plain
|
|
638
|
+
// rebase conflict below (via the thrown gitlinkRebaseError).
|
|
588
639
|
const rebaseStarted = Date.now();
|
|
589
640
|
try {
|
|
590
|
-
|
|
641
|
+
if (gitlinkResolutions.length > 0) {
|
|
642
|
+
const gitlinkRebase = rootRebaseResolvingGitlinks(node.workspace, baseHead, gitlinkResolutions);
|
|
643
|
+
if (!gitlinkRebase.ok) {
|
|
644
|
+
// Surface as a rebase failure so the shared blocked_review handling
|
|
645
|
+
// (submodule-hint recovery included) runs — nothing was left mid-rebase
|
|
646
|
+
// (rootRebaseResolvingGitlinks aborts on failure).
|
|
647
|
+
const err: any = new Error(`gitlink-aware rebase aborted: ${gitlinkRebase.reason || 'unknown'}`);
|
|
648
|
+
err.gitlinkRebaseReason = gitlinkRebase.reason;
|
|
649
|
+
err.gitlinkRebaseConflicts = gitlinkRebase.conflictPaths;
|
|
650
|
+
err.alreadyAborted = true;
|
|
651
|
+
throw err;
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
655
|
+
}
|
|
591
656
|
} catch (rebaseErr: any) {
|
|
592
|
-
|
|
657
|
+
if (!rebaseErr?.alreadyAborted) {
|
|
658
|
+
try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
|
|
659
|
+
}
|
|
593
660
|
// A rebase conflict on a submodule/gitlink divergence is a SPECIAL case the
|
|
594
661
|
// patch-equivalence gate describes with a rich actionable hint (which
|
|
595
662
|
// submodule, base vs branch commit, how to resolve). Run that gate against
|
package/src/commands/router.ts
CHANGED
|
@@ -227,6 +227,14 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
|
|
|
227
227
|
// MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
|
|
228
228
|
// doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
|
|
229
229
|
'send_keys',
|
|
230
|
+
// interactive_prompt_response (mesh_answer_question, mission f1d25e11): the coordinator
|
|
231
|
+
// answers a REMOTE worker's AskUserQuestion (waiting_choice). The answer must reach the
|
|
232
|
+
// OWNING worker session's live instance — its activeInteractivePrompt (the authoritative
|
|
233
|
+
// prompt the labels/indexes resolve against) and its adapter.setInteractivePromptResponse
|
|
234
|
+
// live only there. Without forwarding, the coordinator's local high-family handler returns
|
|
235
|
+
// 'No running instance for session …' and the question is never answered — the exact
|
|
236
|
+
// remote-worker forwarding gap of mission 6938892f, now closed for questions too.
|
|
237
|
+
'interactive_prompt_response',
|
|
230
238
|
]);
|
|
231
239
|
|
|
232
240
|
function normalizeCommandSource(source: string): CommandLogEntry['source'] {
|