@adhdev/daemon-core 1.0.18-rc.10 → 1.0.18-rc.12

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.
Files changed (36) hide show
  1. package/dist/index.js +691 -35
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +691 -35
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/mesh/mesh-active-work.d.ts +1 -1
  6. package/dist/mesh/mesh-ledger.d.ts +1 -1
  7. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  8. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  9. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  10. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  11. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  12. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  13. package/dist/shared-types.d.ts +1 -1
  14. package/package.json +3 -3
  15. package/src/commands/high-family/mesh-events.ts +13 -2
  16. package/src/commands/med-family/mesh-queue.ts +74 -1
  17. package/src/commands/router-refine.ts +71 -4
  18. package/src/commands/router.ts +8 -0
  19. package/src/mesh/coordinator-prompt.ts +2 -1
  20. package/src/mesh/mesh-active-work.ts +11 -1
  21. package/src/mesh/mesh-event-classify.ts +19 -0
  22. package/src/mesh/mesh-event-forwarding.ts +46 -4
  23. package/src/mesh/mesh-events-utils.ts +42 -0
  24. package/src/mesh/mesh-ledger.ts +5 -0
  25. package/src/mesh/mesh-node-identity.ts +49 -0
  26. package/src/mesh/mesh-queue-assignment.ts +18 -1
  27. package/src/mesh/mesh-reconcile-loop.ts +6 -1
  28. package/src/mesh/mesh-refine-gates.ts +300 -0
  29. package/src/mesh/mesh-work-queue.ts +85 -2
  30. package/src/providers/cli-provider-instance-types.ts +28 -0
  31. package/src/providers/cli-provider-instance.ts +90 -15
  32. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  33. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  34. package/src/providers/types/interactive-prompt.ts +77 -0
  35. package/src/shared-types.ts +6 -0
  36. package/src/status/reporter.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  import type { MeshLedgerEntry } from './mesh-ledger.js';
2
2
  import type { MeshWorkQueueEntry, DirectDispatchRecord } from './mesh-work-queue.js';
3
3
  export type MeshActiveWorkSource = 'queue' | 'direct';
4
- export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval';
4
+ export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval' | 'awaiting_choice';
5
5
  export interface MeshActiveWorkRecord {
6
6
  taskId: string;
7
7
  source: MeshActiveWorkSource;
@@ -15,7 +15,7 @@
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
17
  import { type MeshLedgerOriginatingCoordinatorV2 } from './contracts.js';
18
- 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' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection' | 'worktree_cleanup_candidate';
18
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'task_question_pending' | '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' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection' | 'worktree_cleanup_candidate';
19
19
  export interface MeshLedgerEntry {
20
20
  id: string;
21
21
  meshId: string;
@@ -107,6 +107,29 @@ export declare function resolveEffectiveMeshNodeHealth(node: any): string;
107
107
  * replica that can never run.
108
108
  */
109
109
  export declare function isMeshNodeHealthLaunchable(node: any): boolean;
110
+ /**
111
+ * Launch FRESHNESS gate — distinct from the health gate above.
112
+ *
113
+ * `deriveMeshNodeHealthFromGit` reports a clean-tree node with `behind > 0` as
114
+ * 'online', so the health gate (isMeshNodeHealthLaunchable) happily lets a STALE
115
+ * node — one whose branch is N commits behind its upstream — win auto-launch fitness
116
+ * routing and run a fresh worker against out-of-date code. `behind` is deliberately
117
+ * NOT folded into deriveMeshNodeHealthFromGit because "behind" is not universally
118
+ * unhealthy (the MAGI planner and other callers share that resolver); it is only
119
+ * unhealthy for *spawning new work*. This gate encodes exactly that launch-time axis.
120
+ *
121
+ * Returns false (NOT fresh → skip / de-rank) only when git telemetry is PRESENT and
122
+ * proves staleness:
123
+ * - behind count exceeds `maxBehind` (default 0 — any behind blocks), OR
124
+ * - a submodule is out of sync (gitlink points off upstream — cannot be caught up
125
+ * by a simple worktree ff and would launch against a mismatched submodule).
126
+ *
127
+ * When telemetry is absent it returns true (fresh), preserving the online/unknown-pass
128
+ * philosophy: we never block on missing data, only on data that proves the node stale.
129
+ */
130
+ export declare function isMeshNodeFreshEnoughToLaunch(node: any, opts?: {
131
+ maxBehind?: number;
132
+ }): boolean;
110
133
  export declare function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<string, unknown>): void;
111
134
  export declare function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown>;
112
135
  export declare function readCachedInlineMeshActiveSessions(node: any): string[];
@@ -504,6 +504,95 @@ type GitlinkTrivialFastForwardEvaluation = {
504
504
  * gate.
505
505
  */
506
506
  export declare function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: string, branchHead: string): string[];
507
+ export type SubmoduleGitlinkConvergeResult = {
508
+ /** True when at least one diverged gitlink submodule was rebased onto its base-side commit. */
509
+ converged: boolean;
510
+ /**
511
+ * Set when convergence was declined/aborted (fail-safe → caller keeps the
512
+ * original defer→blocked_review path). One of:
513
+ * no_changed_gitlinks — no gitlink differs base↔branch
514
+ * not_diverged — the gitlink is ff/behind/equal (gate handles it)
515
+ * rebase_conflict — replaying branch-side onto base-side hit a real
516
+ * content conflict inside the submodule (aborted)
517
+ */
518
+ reason?: string;
519
+ /**
520
+ * Converged gitlinks: path → the rebased submodule commit (SUBNEW) that the
521
+ * root rebase must resolve the gitlink conflict to. Only populated for paths
522
+ * whose submodule was successfully rebased (base-side is now a strict ancestor).
523
+ */
524
+ resolutions: Array<{
525
+ path: string;
526
+ baseCommit: string;
527
+ branchCommit: string;
528
+ rebasedCommit: string;
529
+ }>;
530
+ /** Per-path outcome for observability/logging. */
531
+ gitlinks: Array<{
532
+ path: string;
533
+ baseCommit?: string;
534
+ branchCommit?: string;
535
+ rebasedCommit?: string;
536
+ action: 'rebased' | 'skipped_not_diverged' | 'rebase_conflict';
537
+ }>;
538
+ };
539
+ /**
540
+ * STEP 1 of auto-converging diverged oss-style submodule gitlinks: rebase the
541
+ * branch-side submodule commit(s) onto the base-side submodule commit INSIDE the
542
+ * worktree's submodule checkout (detached HEAD), so the base-side commit becomes a
543
+ * strict ancestor of the rebased tip. Returns the rebased commit per path so the
544
+ * caller's root rebase can resolve the gitlink conflict to it (STEP 2, see
545
+ * {@link rootRebaseResolvingGitlinks}). This automates the documented manual
546
+ * strict-fast-forward bypass and keeps the landed submodule history linear rather
547
+ * than masking a divergence.
548
+ *
549
+ * Fail-safe by construction: it only touches gitlinks that are a genuine sibling
550
+ * divergence (both commits local, neither an ancestor of the other, shared merge
551
+ * base). A submodule rebase content conflict aborts, restores the branch-side
552
+ * checkout, and returns `converged:false` (caller keeps defer→blocked_review). It
553
+ * never commits the root and NEVER pushes — remote publish is the merge/
554
+ * reachability stage's job; this stage is local reconciliation only.
555
+ *
556
+ * @param worktreeRoot the branch worktree root (its `<path>` submodule checkout is rebased)
557
+ * @param baseRepoRoot the source/base repo root (reads the base-side gitlink commit)
558
+ * @param baseHead the fetched base head (root ref) — source of base-side gitlink commits
559
+ * @param branchHead the worktree branch head (root ref) — source of branch-side gitlink commits
560
+ */
561
+ export declare function convergeDivergedSubmoduleGitlinks(worktreeRoot: string, baseRepoRoot: string, baseHead: string, branchHead: string): SubmoduleGitlinkConvergeResult;
562
+ export type RootRebaseGitlinkResolveResult = {
563
+ /** True when the root rebase completed (with gitlink conflicts resolved to the converged commits). */
564
+ ok: boolean;
565
+ /** New root HEAD after the rebase (only meaningful when ok). */
566
+ branchHead?: string;
567
+ /**
568
+ * Set when the rebase was aborted (fail-safe). One of:
569
+ * non_gitlink_conflict — a conflict on a non-submodule path (genuine content conflict)
570
+ * unexpected_gitlink — a gitlink conflicted that we have no converged commit for
571
+ * rebase_error — the rebase failed for a non-conflict reason
572
+ */
573
+ reason?: string;
574
+ /** The paths that conflicted at the point of abort (for diagnostics). */
575
+ conflictPaths?: string[];
576
+ };
577
+ /**
578
+ * STEP 2 of auto-converging diverged submodule gitlinks: rebase the worktree root
579
+ * branch onto `baseHead`, resolving each submodule-gitlink conflict to the
580
+ * pre-converged commit from {@link convergeDivergedSubmoduleGitlinks}. git's
581
+ * recursive merge refuses to auto-merge a diverged gitlink ("Recursive merging
582
+ * with submodules currently only supports trivial cases"), so we drive the rebase
583
+ * ourselves: on each stop, if the ONLY unmerged paths are gitlinks we have a
584
+ * converged commit for, we stage those to the converged commit and `--continue`.
585
+ * Any non-gitlink conflict (or a gitlink with no converged commit) aborts the
586
+ * rebase and returns `ok:false` → caller keeps the defer→blocked_review path.
587
+ *
588
+ * On success the base-side gitlink is a strict ancestor of the resolved branch-side
589
+ * commit, so the downstream patch-equivalence gate treats it as a trivial
590
+ * fast-forward and passes.
591
+ */
592
+ export declare function rootRebaseResolvingGitlinks(worktreeRoot: string, baseHead: string, resolutions: Array<{
593
+ path: string;
594
+ rebasedCommit: string;
595
+ }>): RootRebaseGitlinkResolveResult;
507
596
  /**
508
597
  * Decide whether a merge-tree submodule conflict between base and branch is a
509
598
  * trivial gitlink fast-forward (and nothing else).
@@ -337,7 +337,15 @@ export type DependencyFailurePolicy = 'block' | 'cancel';
337
337
  * Update the status of a specific task.
338
338
  * Used when a session completes, fails, or stalls.
339
339
  */
340
- export declare function updateTaskStatus(meshId: string, taskId: string, status: MeshTaskStatus, opts?: MeshQueueMutationOptions): MeshWorkQueueEntry | null;
340
+ export declare function updateTaskStatus(meshId: string, taskId: string, status: MeshTaskStatus, opts?: {
341
+ /**
342
+ * CANCEL-STICKY-TERMINAL: operator/system override to permit a terminal→non-terminal
343
+ * transition (e.g. an explicit operator reopen). Without this, a write that would flip
344
+ * a `completed`/`failed`/`cancelled` row back to `pending`/`assigned` is refused as a
345
+ * no-op. Terminal→terminal and any transition FROM a non-terminal state are unaffected.
346
+ */
347
+ force?: boolean;
348
+ } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
341
349
  export declare function recordTaskAutoLaunch(meshId: string, taskId: string, autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>): MeshWorkQueueEntry | null;
342
350
  /**
343
351
  * Mark a queue task as manually cancelled without deleting audit history.
@@ -345,6 +353,21 @@ export declare function recordTaskAutoLaunch(meshId: string, taskId: string, aut
345
353
  export declare function cancelTask(meshId: string, taskId: string, opts?: {
346
354
  reason?: string;
347
355
  } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
356
+ /**
357
+ * CANCEL-STICKY-TERMINAL: the assignment a task carried at cancel time, handed to the cancel
358
+ * command handler so it can stop the bound worker. cancelTask runs in the pure queue-store
359
+ * module (no DaemonComponents), so it records the binding here and the handler drains it.
360
+ */
361
+ export interface CancelledTaskAssignment {
362
+ sessionId: string;
363
+ nodeId?: string;
364
+ providerType?: string;
365
+ }
366
+ /**
367
+ * Read-and-clear the assignment a just-cancelled task was bound to. Returns undefined when the
368
+ * cancelled task had no live assignment (nothing to stop). One-shot: the entry is deleted on read.
369
+ */
370
+ export declare function takeCancelledTaskAssignment(meshId: string, taskId: string): CancelledTaskAssignment | undefined;
348
371
  /**
349
372
  * Return a queue task to pending for retry. By default, dead session targeting
350
373
  * and assigned ownership are cleared so stale assignments do not strand again.
@@ -19,6 +19,7 @@ export type CompletedFinalizationBlock = {
19
19
  terminal?: boolean;
20
20
  allowTimeout?: boolean;
21
21
  holdForTranscript?: boolean;
22
+ noExternalTranscriptSource?: boolean;
22
23
  };
23
24
  export type CompletionFinalAssistantEvidence = {
24
25
  present: boolean;
@@ -37,6 +38,7 @@ export type ExternalTranscriptProbe = {
37
38
  };
38
39
  export declare const COMPLETED_FINALIZATION_RETRY_MS = 1000;
39
40
  export declare const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30000;
41
+ export declare const CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS = 20000;
40
42
  export declare const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
41
43
  export declare const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
42
44
  export declare const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
@@ -75,6 +75,20 @@ export interface DetectStatusTuiSpec {
75
75
  declare function scopeText(input: CliStatusInput, scope: SpinnerSpec['scope'] | SettledPromptSpec['scope'], windowLines: number | undefined): string;
76
76
  declare function spinnerMatches(spec: SpinnerSpec, input: CliStatusInput): boolean;
77
77
  declare function settledPromptMatches(spec: SettledPromptSpec, input: CliStatusInput): boolean;
78
+ /**
79
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: an
80
+ * AskUserQuestion multi-choice picker is NOT an approval modal. Its option rows
81
+ * ("❯ 1. label") can otherwise satisfy the approval button cue and get
82
+ * mis-classified as `waiting_approval`, so the worker's question is surfaced to
83
+ * the coordinator as a task_approval_needed (→ mesh_approve, which cannot answer
84
+ * it). The picker carries a distinctive signature the approval FSM never does:
85
+ * the claude TUI select footer ("Enter to select … Esc to cancel") together with
86
+ * the freeform escape hatch ("Type something" / "Chat about this"). When both are
87
+ * present the screen is a question picker — surfaced separately as
88
+ * waiting_choice — so the approval matchers must yield. Mirrors the legacy
89
+ * looksLikeSelectionPicker guard (cli-provider-instance.ts) ported to SDK-v1.
90
+ */
91
+ export declare function isAskUserQuestionPickerSignature(text: string): boolean;
78
92
  declare function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean;
79
93
  export declare function buildDetectStatusFromTui(spec: DetectStatusTuiSpec): CliDetectStatusFn;
80
94
  export declare const __internal: {
@@ -28,6 +28,24 @@ export interface InteractiveAnswer {
28
28
  }
29
29
  export declare function normalizeInteractivePrompt(raw: unknown): InteractivePrompt | null;
30
30
  export declare function normalizeInteractivePromptResponse(raw: unknown): InteractivePromptResponse;
31
+ /**
32
+ * Resolve a coordinator-friendly answer form into the strict, questionId-keyed
33
+ * InteractivePromptResponse the TUI/answer machinery consumes (mission f1d25e11).
34
+ *
35
+ * mesh_answer_question lets the coordinator answer against the option LABELS or
36
+ * 1-based INDEXES it saw in the agent:waiting_choice event, without having to
37
+ * reconstruct the exact questionId → selectedLabels map. This resolves that
38
+ * ergonomic form against the AUTHORITATIVE active prompt (the daemon holds it),
39
+ * so index/label resolution and question ordering are correct by construction.
40
+ *
41
+ * Accepted `raw` shapes:
42
+ * - The strict form ({ promptId, answers: { [questionId]: { selectedLabels } } })
43
+ * — passed straight to normalizeInteractivePromptResponse (back-compat).
44
+ * - The friendly form ({ promptId, answers: [ { questionId?, select?, freeform? } ] })
45
+ * — entries map to questions by questionId, else by array position. `select`
46
+ * is a label (string) / 1-based index (number) / array of either.
47
+ */
48
+ export declare function resolveInteractivePromptResponse(prompt: InteractivePrompt, raw: unknown): InteractivePromptResponse;
31
49
  export declare function buildClaudeInteractiveToolResult(response: InteractivePromptResponse): string;
32
50
  export interface ClaudeInteractiveTuiPage {
33
51
  screenText: string;
@@ -624,7 +624,7 @@ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummar
624
624
  p2p?: StatusReportPayload['p2p'];
625
625
  timestamp?: number;
626
626
  }
627
- export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
627
+ export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:waiting_choice' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
628
628
  /** Minimal daemon-originated event payload relayed through the server. */
629
629
  export interface DaemonStatusEventPayload {
630
630
  event: DaemonStatusEventName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.10",
3
+ "version": "1.0.18-rc.12",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.10",
51
- "@adhdev/session-host-core": "1.0.18-rc.10",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.12",
51
+ "@adhdev/session-host-core": "1.0.18-rc.12",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -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 response = normalizeInteractivePromptResponse(args?.response ?? args);
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
- ctx.deps.instanceManager.sendEvent(sessionId, 'interactive_prompt_response', response);
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
  };
@@ -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 || submoduleConflict) {
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: alreadyMerged ? 'already_merged_via_other_path_skip_rebase' : 'submodule_conflict_defer_to_patch_equivalence',
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
- execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
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
- try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
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
@@ -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'] {
@@ -1007,7 +1007,8 @@ const TOOLS_SECTION = `## Available Tools
1007
1007
  | \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
1008
1008
  | \`mesh_restart_daemon\` | Update a node's daemon to the latest published version on its channel and restart it (the dashboard "preview update" path, as a mesh command) |
1009
1009
  | \`mesh_checkpoint\` | Create a git checkpoint on a node |
1010
- | \`mesh_approve\` | Approve/reject a pending agent action |
1010
+ | \`mesh_approve\` | Approve/reject a pending agent action (a yes/no tool-consent modal) |
1011
+ | \`mesh_answer_question\` | Answer a delegated session's multi-choice QUESTION (AskUserQuestion / status awaiting_choice). NOT an approval — it offers labelled options (possibly multi-select or freeform). Pass the promptId from the agent:waiting_choice event + one answer per question (select by option label or 1-based index). Use this, never mesh_approve, for a question |
1011
1012
  | \`mesh_list_pending_approvals\` | List every session across the mesh awaiting an approval decision (the approval inbox) — read-only; enumerate all blocked sessions at once, then drive a mesh_approve for each |
1012
1013
  | \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
1013
1014
  | \`mesh_refine_node\` | Validate and merge a completed worktree node back into its base branch |
@@ -5,7 +5,7 @@ import { deleteDirectDispatchesByTaskId } from './mesh-work-queue.js';
5
5
  import { meshNodeIdMatches, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
6
6
 
7
7
  export type MeshActiveWorkSource = 'queue' | 'direct';
8
- export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval';
8
+ export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval' | 'awaiting_choice';
9
9
 
10
10
  export interface MeshActiveWorkRecord {
11
11
  taskId: string;
@@ -135,6 +135,10 @@ export function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string
135
135
  if (!session) return { staleReason: 'direct task session is not present in live session records' };
136
136
  if (typeof session === 'string') return {};
137
137
  const raw = `${readString(session.status) || ''} ${readString(session.lifecycle) || ''} ${readString(session.state) || ''} ${readString(session.activeChat?.status) || ''}`.toLowerCase();
138
+ // A question picker surfaces as waiting_choice — distinct from an approval modal.
139
+ // Check it first so a question worker is not mislabeled awaiting_approval and
140
+ // pulled into the approval inbox (mission f1d25e11).
141
+ if (raw.includes('waiting_choice') || raw.includes('choice')) return { status: 'awaiting_choice' };
138
142
  if (raw.includes('approval')) return { status: 'awaiting_approval' };
139
143
  if (raw.includes('generating') || raw.includes('running') || raw.includes('busy')) return { status: 'generating' };
140
144
  if (raw.includes('failed') || raw.includes('stopped') || raw.includes('terminated') || raw.includes('exited')) return { status: 'failed' };
@@ -166,6 +170,10 @@ function terminalMatchesDispatch(terminal: MeshLedgerEntry, dispatch: MeshLedger
166
170
 
167
171
  function statusFromTerminal(entry: MeshLedgerEntry): MeshActiveWorkStatus {
168
172
  if (entry.kind === 'task_approval_needed') return 'awaiting_approval';
173
+ // A question (waiting_choice) is a distinct blocked state — kept OUT of
174
+ // awaiting_approval so it is not surfaced in the approval inbox / mesh_approve
175
+ // flow (mission f1d25e11). Answered via mesh_answer_question.
176
+ if (entry.kind === 'task_question_pending') return 'awaiting_choice';
169
177
  if (entry.kind === 'task_completed') return 'idle';
170
178
  return 'failed';
171
179
  }
@@ -331,6 +339,7 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
331
339
  idle: 0,
332
340
  failed: 0,
333
341
  awaiting_approval: 0,
342
+ awaiting_choice: 0,
334
343
  };
335
344
  const sourceCounts: Record<MeshActiveWorkSource, number> = { queue: 0, direct: 0 };
336
345
  for (const item of activeWork) {
@@ -380,6 +389,7 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
380
389
  ? sessionStatusFromNodes(opts.nodes, queueNodeId ?? undefined, queueSessionId ?? undefined)
381
390
  : {};
382
391
  const queueStatus: MeshActiveWorkStatus = queueLive.status === 'awaiting_approval'
392
+ || queueLive.status === 'awaiting_choice'
383
393
  || queueLive.status === 'generating'
384
394
  ? queueLive.status
385
395
  : task.status;
@@ -8,6 +8,11 @@ const MESH_COORDINATOR_EVENTS = new Set([
8
8
  'agent:generating_started',
9
9
  'agent:generating_completed',
10
10
  'agent:waiting_approval',
11
+ // A worker parked on an AskUserQuestion multi-choice prompt. DISTINCT from
12
+ // agent:waiting_approval (a yes/no tool-consent modal): a question is answered
13
+ // with mesh_answer_question, never mesh_approve (mission f1d25e11). Carries the
14
+ // full InteractivePrompt payload so the coordinator can render + answer.
15
+ 'agent:waiting_choice',
11
16
  'agent:stopped',
12
17
  'agent:ready',
13
18
  'monitor:no_progress',
@@ -26,6 +31,7 @@ const MESH_COORDINATOR_EVENTS = new Set([
26
31
  export const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
27
32
  'agent:generating_completed': 'task_completed',
28
33
  'agent:waiting_approval': 'task_approval_needed',
34
+ 'agent:waiting_choice': 'task_question_pending',
29
35
  'agent:stopped': 'task_failed',
30
36
  'monitor:no_progress': 'task_stalled',
31
37
  };
@@ -45,6 +51,11 @@ export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
45
51
  'agent:generating_completed',
46
52
  'agent:stopped',
47
53
  'agent:waiting_approval',
54
+ // A worker's question (waiting_choice) is real-time and the coordinator may be
55
+ // generating when it arrives; force-inject it like approval so the busy coordinator
56
+ // learns it must answer (mesh_answer_question) rather than the nudge sitting in the
57
+ // adapter's outbound queue until the coordinator idles on its own (mission f1d25e11).
58
+ 'agent:waiting_choice',
48
59
  'refine:completed',
49
60
  'refine:failed',
50
61
  'worktree_bootstrap_complete',
@@ -71,6 +82,14 @@ export function shouldForceInjectMeshEvent(eventName: unknown): boolean {
71
82
  // require, and why a stale/resolved approval nudge can simply be dropped.
72
83
  export const MESH_APPROVAL_EVENTS: ReadonlySet<string> = new Set([
73
84
  'agent:waiting_approval',
85
+ // agent:waiting_choice (a worker's AskUserQuestion) shares the approval class's
86
+ // real-time-nudge / level-backed contract: its authoritative state is recorded at
87
+ // LEVEL the moment it is processed (task_question_pending → mesh_status
88
+ // awaiting_choice), so the pending event is only a NUDGE and can be delivered to a
89
+ // busy coordinator's inbox (and dropped) without data loss — the level state
90
+ // re-derives it. It is therefore exempt from the idle-edge hold completions require,
91
+ // exactly like an approval nudge (mission f1d25e11).
92
+ 'agent:waiting_choice',
74
93
  ]);
75
94
 
76
95
  export function isMeshApprovalEvent(eventName: unknown): boolean {