@songsid/agend 2.1.5-beta.14 → 2.1.5-beta.16
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/backend/claude-code.d.ts +1 -0
- package/dist/backend/claude-code.js +1 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +15 -0
- package/dist/channel/adapters/discord.js +26 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/daemon.d.ts +9 -9
- package/dist/daemon.js +308 -97
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +20 -0
- package/dist/fleet-manager.js +57 -3
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.js +30 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +41 -0
- package/dist/turn-reply-guard.js +77 -0
- package/dist/turn-reply-guard.js.map +1 -0
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -24,6 +24,8 @@ import { PaneWriteLock } from "./pane-write-lock.js";
|
|
|
24
24
|
import { buildFleetInstructions } from "./instructions.js";
|
|
25
25
|
import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
|
|
26
26
|
import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
|
|
27
|
+
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
28
|
+
import { t } from "./locale.js";
|
|
27
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
30
|
const __dirname = dirname(__filename);
|
|
29
31
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -34,6 +36,8 @@ const TASK_TOOL = "task";
|
|
|
34
36
|
// Tools whose success proves the agent got a message out this turn. While any
|
|
35
37
|
// of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
|
|
36
38
|
const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
|
|
39
|
+
const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
40
|
+
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
|
|
37
41
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
38
42
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
39
43
|
const source = binaryName === "codex" || binaryName === "grok"
|
|
@@ -377,9 +381,10 @@ export class PendingWorkTracker {
|
|
|
377
381
|
// An async pane poll can finish after a newer inbound. Do not let its stale
|
|
378
382
|
// observation clear work which had not arrived when the pane was captured.
|
|
379
383
|
if (now < this.lastInboundAt)
|
|
380
|
-
return;
|
|
384
|
+
return false;
|
|
381
385
|
this.lastIdleAt = now;
|
|
382
386
|
this.lastIdleOrder = ++this.sequence;
|
|
387
|
+
return true;
|
|
383
388
|
}
|
|
384
389
|
hasPendingWork() {
|
|
385
390
|
return this.lastInboundOrder > this.lastIdleOrder;
|
|
@@ -882,19 +887,14 @@ export class Daemon extends EventEmitter {
|
|
|
882
887
|
instanceStateMonitorActive = false;
|
|
883
888
|
sessionCheckpointWarningEmitted = false;
|
|
884
889
|
statePollInFlight = false;
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
|
|
889
|
-
turnHadInbound = false;
|
|
890
|
-
turnOutboundDelivered = false;
|
|
891
|
-
/** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
|
|
892
|
-
turnReplyDelivered = false;
|
|
893
|
-
turnCorrelationId;
|
|
894
|
-
turnInboundMarker;
|
|
890
|
+
// One generation-scoped source of truth for human-turn reply completion.
|
|
891
|
+
// It also supplies the older dead-MCP/malformed-call recovery paths, so those
|
|
892
|
+
// paths cannot disagree about whether the turn already spoke to the channel.
|
|
893
|
+
turnReplyGuard = new TurnReplyGuard();
|
|
895
894
|
/** Prevent a visible stale XML fragment from being recovered on later turns. */
|
|
896
895
|
lastMalformedToolCallSignature;
|
|
897
896
|
proxyReplySeq = 0;
|
|
897
|
+
lastReplyDropWarningAt = 0;
|
|
898
898
|
autoPauseController;
|
|
899
899
|
pauseRequested = false;
|
|
900
900
|
/**
|
|
@@ -2519,6 +2519,7 @@ export class Daemon extends EventEmitter {
|
|
|
2519
2519
|
}
|
|
2520
2520
|
async stop() {
|
|
2521
2521
|
this.logger.info("Stopping daemon instance");
|
|
2522
|
+
this.turnReplyGuard.reset();
|
|
2522
2523
|
this.freezeRuntimeMonitors();
|
|
2523
2524
|
this.pendingIpcRequests.clear();
|
|
2524
2525
|
if (this.adapter)
|
|
@@ -2812,7 +2813,7 @@ export class Daemon extends EventEmitter {
|
|
|
2812
2813
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
2813
2814
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
2814
2815
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
2815
|
-
this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2816
|
+
const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2816
2817
|
// The turn is over. A transcript can end on a tool_use with no matching
|
|
2817
2818
|
// tool_result (interrupted, crashed, cancelled), which would otherwise leave
|
|
2818
2819
|
// the last tool pinned to the progress line for the rest of the session.
|
|
@@ -2820,7 +2821,8 @@ export class Daemon extends EventEmitter {
|
|
|
2820
2821
|
this.resetToolProgress();
|
|
2821
2822
|
// Must run before the mcpRestartPending branch below: the pane text is the
|
|
2822
2823
|
// only copy of the answer, and the revival restart is about to clear it.
|
|
2823
|
-
|
|
2824
|
+
if (acceptedIdle)
|
|
2825
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
2824
2826
|
}
|
|
2825
2827
|
if (snapshot.state !== previous) {
|
|
2826
2828
|
this.logger.info({
|
|
@@ -2871,13 +2873,17 @@ export class Daemon extends EventEmitter {
|
|
|
2871
2873
|
// a task result, and a stale one (sol's review of #515).
|
|
2872
2874
|
if (meta.from_instance || !meta.chat_id)
|
|
2873
2875
|
return;
|
|
2874
|
-
this.turnHadInbound = true;
|
|
2875
|
-
this.turnOutboundDelivered = false;
|
|
2876
|
-
this.turnReplyDelivered = false;
|
|
2877
|
-
this.turnCorrelationId = meta.correlation_id || undefined;
|
|
2878
2876
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
2879
2877
|
// is the agent's own output.
|
|
2880
|
-
|
|
2878
|
+
const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
|
|
2879
|
+
this.turnReplyGuard.arm({
|
|
2880
|
+
adapterId: meta.adapter_id || undefined,
|
|
2881
|
+
chatId: meta.chat_id,
|
|
2882
|
+
threadId: meta.thread_id || undefined,
|
|
2883
|
+
messageId: meta.message_id || undefined,
|
|
2884
|
+
correlationId: meta.correlation_id || undefined,
|
|
2885
|
+
inboundMarker,
|
|
2886
|
+
});
|
|
2881
2887
|
}
|
|
2882
2888
|
/**
|
|
2883
2889
|
* Turn ended (busy→idle edge): if the MCP server is dead and none of the
|
|
@@ -2887,56 +2893,197 @@ export class Daemon extends EventEmitter {
|
|
|
2887
2893
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
2888
2894
|
*/
|
|
2889
2895
|
maybeProxyReplyOnTurnEnd(pane) {
|
|
2890
|
-
const
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2896
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
2897
|
+
if (!turn || this.isPaused)
|
|
2898
|
+
return;
|
|
2899
|
+
if (turn.replyDelivered) {
|
|
2900
|
+
if (turn.phase === "recovering") {
|
|
2901
|
+
this.emit("reply_drop_recovered", {
|
|
2902
|
+
name: this.name,
|
|
2903
|
+
correlationId: turn.target.correlationId,
|
|
2904
|
+
generation: turn.generation,
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2901
2908
|
return;
|
|
2909
|
+
}
|
|
2910
|
+
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
2911
|
+
// third turn or guess at terminal text; make the failure visible instead.
|
|
2912
|
+
if (turn.phase === "recovering") {
|
|
2913
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2914
|
+
this.reportUnrecoveredReplyDrop(turn);
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2902
2917
|
// Claude sometimes prints a broken XML tool call as plain text and returns
|
|
2903
2918
|
// idle without ever invoking `reply`. This is independent of MCP liveness:
|
|
2904
2919
|
// the model malformed the call before the server could receive it.
|
|
2905
|
-
if (
|
|
2906
|
-
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
|
|
2920
|
+
if (this.isClaudeCodeBackend() && pane) {
|
|
2921
|
+
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
|
|
2907
2922
|
if (malformed) {
|
|
2908
2923
|
const stale = malformed.signature === this.lastMalformedToolCallSignature;
|
|
2909
2924
|
this.lastMalformedToolCallSignature = malformed.signature;
|
|
2910
2925
|
if (!stale) {
|
|
2911
|
-
|
|
2912
|
-
this.logger.warn({ correlationId, recovered }, recovered
|
|
2926
|
+
this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
|
|
2913
2927
|
? "Malformed Claude tool call detected — attempting to recover reply text"
|
|
2914
2928
|
: "Malformed Claude tool call detected — reply text could not be extracted");
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2929
|
+
if (malformed.text) {
|
|
2930
|
+
this.queueMalformedReplyRecovery(turn, malformed.text);
|
|
2931
|
+
}
|
|
2932
|
+
else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
|
|
2933
|
+
this.emit("malformed_tool_call", {
|
|
2934
|
+
name: this.name,
|
|
2935
|
+
correlationId: turn.target.correlationId,
|
|
2936
|
+
recovered: false,
|
|
2937
|
+
recoveryStarted: true,
|
|
2938
|
+
});
|
|
2939
|
+
this.startReplyRecovery(turn, "malformed_call");
|
|
2940
|
+
}
|
|
2941
|
+
else {
|
|
2942
|
+
this.emit("malformed_tool_call", {
|
|
2943
|
+
name: this.name,
|
|
2944
|
+
correlationId: turn.target.correlationId,
|
|
2945
|
+
recovered: false,
|
|
2946
|
+
});
|
|
2947
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
else {
|
|
2951
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2918
2952
|
}
|
|
2919
2953
|
// Never let the broader dead-MCP fallback relay the XML/chrome too.
|
|
2920
2954
|
return;
|
|
2921
2955
|
}
|
|
2922
2956
|
}
|
|
2923
|
-
|
|
2957
|
+
// Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
|
|
2958
|
+
// tools still suppress that proxy, but only a delivered `reply` satisfies
|
|
2959
|
+
// the human-facing completion guard below.
|
|
2960
|
+
if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
|
|
2961
|
+
&& mcpServerState(this.instanceDir).state === "dead") {
|
|
2962
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2963
|
+
void this.sendProxyReply(pane, turn.target);
|
|
2924
2964
|
return;
|
|
2925
|
-
|
|
2926
|
-
if (this.
|
|
2965
|
+
}
|
|
2966
|
+
if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
|
|
2967
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2927
2968
|
return;
|
|
2928
|
-
|
|
2929
|
-
if (
|
|
2969
|
+
}
|
|
2970
|
+
if (turn.replyAttempted) {
|
|
2971
|
+
// A provider timeout can be "applied, then timed out". Retrying it would
|
|
2972
|
+
// risk a duplicate; report the unknown result and stop here.
|
|
2973
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2974
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
2930
2975
|
return;
|
|
2931
|
-
|
|
2976
|
+
}
|
|
2977
|
+
this.startReplyRecovery(turn, "no_valid_call");
|
|
2932
2978
|
}
|
|
2933
2979
|
isClaudeCodeBackend() {
|
|
2934
2980
|
return this.runtimeIdentity?.backend === "claude-code"
|
|
2935
2981
|
|| this.config.backend === "claude-code"
|
|
2936
2982
|
|| this.backend?.binaryName === "claude";
|
|
2937
2983
|
}
|
|
2984
|
+
replyCompletionGuardEnabled() {
|
|
2985
|
+
return this.backend?.replyCompletionGuard === true;
|
|
2986
|
+
}
|
|
2987
|
+
startReplyRecovery(turn, reason) {
|
|
2988
|
+
if (!this.turnReplyGuard.beginRecovery(turn.generation))
|
|
2989
|
+
return;
|
|
2990
|
+
this.emit("reply_drop_detected", {
|
|
2991
|
+
name: this.name,
|
|
2992
|
+
correlationId: turn.target.correlationId,
|
|
2993
|
+
generation: turn.generation,
|
|
2994
|
+
reason,
|
|
2995
|
+
recoveryStarted: true,
|
|
2996
|
+
});
|
|
2997
|
+
void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
|
|
2998
|
+
this.queueReplyRecoveryPrompt(turn);
|
|
2999
|
+
}
|
|
3000
|
+
reportReplyDropWithoutRetry(turn, reason) {
|
|
3001
|
+
this.emit("reply_drop_detected", {
|
|
3002
|
+
name: this.name,
|
|
3003
|
+
correlationId: turn.target.correlationId,
|
|
3004
|
+
generation: turn.generation,
|
|
3005
|
+
reason,
|
|
3006
|
+
recoveryStarted: false,
|
|
3007
|
+
});
|
|
3008
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
|
|
3009
|
+
}
|
|
3010
|
+
queueReplyRecoveryPrompt(turn) {
|
|
3011
|
+
const deliveryEpoch = this.deliveryEpoch;
|
|
3012
|
+
this.pasteQueueDepth++;
|
|
3013
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3014
|
+
try {
|
|
3015
|
+
const current = this.turnReplyGuard.snapshot();
|
|
3016
|
+
if (!current || current.generation !== turn.generation || current.phase !== "recovering")
|
|
3017
|
+
return;
|
|
3018
|
+
if (current.replyDelivered) {
|
|
3019
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3020
|
+
this.emit("reply_drop_recovered", {
|
|
3021
|
+
name: this.name,
|
|
3022
|
+
correlationId: current.target.correlationId,
|
|
3023
|
+
generation: current.generation,
|
|
3024
|
+
});
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
|
|
3028
|
+
deliveryEpoch,
|
|
3029
|
+
});
|
|
3030
|
+
if (!delivered) {
|
|
3031
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3032
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
finally {
|
|
3036
|
+
this.pasteQueueDepth--;
|
|
3037
|
+
}
|
|
3038
|
+
}).catch(err => {
|
|
3039
|
+
this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
|
|
3040
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3041
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
queueMalformedReplyRecovery(turn, text) {
|
|
3045
|
+
// Serialize the delivery decision ahead of any new pane input. If direct
|
|
3046
|
+
// delivery is positively acknowledged, there is no recovery turn. If its
|
|
3047
|
+
// outcome is unknown, do not ask the model to duplicate it.
|
|
3048
|
+
const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
|
|
3049
|
+
this.pasteQueueDepth++;
|
|
3050
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3051
|
+
try {
|
|
3052
|
+
const delivered = await delivery;
|
|
3053
|
+
this.emit("malformed_tool_call", {
|
|
3054
|
+
name: this.name,
|
|
3055
|
+
correlationId: turn.target.correlationId,
|
|
3056
|
+
recovered: delivered,
|
|
3057
|
+
});
|
|
3058
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3059
|
+
if (!delivered)
|
|
3060
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3061
|
+
}
|
|
3062
|
+
finally {
|
|
3063
|
+
this.pasteQueueDepth--;
|
|
3064
|
+
}
|
|
3065
|
+
}).catch(err => {
|
|
3066
|
+
this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
|
|
3067
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3068
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
|
|
3072
|
+
this.emit("reply_drop_unrecovered", {
|
|
3073
|
+
name: this.name,
|
|
3074
|
+
correlationId: turn.target.correlationId,
|
|
3075
|
+
generation: turn.generation,
|
|
3076
|
+
reason,
|
|
3077
|
+
});
|
|
3078
|
+
const now = Date.now();
|
|
3079
|
+
if (this.lastReplyDropWarningAt !== 0
|
|
3080
|
+
&& now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
|
|
3081
|
+
return;
|
|
3082
|
+
this.lastReplyDropWarningAt = now;
|
|
3083
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
|
|
3084
|
+
}
|
|
2938
3085
|
/** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
|
|
2939
|
-
async sendProxyReply(pane,
|
|
3086
|
+
async sendProxyReply(pane, target) {
|
|
2940
3087
|
try {
|
|
2941
3088
|
// The idle capture normally hands its pane in; fall back only when the
|
|
2942
3089
|
// edge came from a path without one.
|
|
@@ -2944,74 +3091,110 @@ export class Daemon extends EventEmitter {
|
|
|
2944
3091
|
pane = await this.tmux?.capturePane();
|
|
2945
3092
|
if (!pane)
|
|
2946
3093
|
return;
|
|
2947
|
-
const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
3094
|
+
const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
2948
3095
|
if (!text) {
|
|
2949
3096
|
this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
|
|
2950
3097
|
return;
|
|
2951
3098
|
}
|
|
2952
3099
|
let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
|
|
2953
|
-
if (correlationId)
|
|
2954
|
-
body += `\n\n(correlation_id: ${correlationId})`;
|
|
2955
|
-
this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply —
|
|
2956
|
-
this.
|
|
2957
|
-
|
|
3100
|
+
if (target.correlationId)
|
|
3101
|
+
body += `\n\n(correlation_id: ${target.correlationId})`;
|
|
3102
|
+
this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
|
|
3103
|
+
const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
|
|
3104
|
+
if (delivered)
|
|
3105
|
+
this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
|
|
2958
3106
|
}
|
|
2959
3107
|
catch (err) {
|
|
2960
3108
|
this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
|
|
2961
3109
|
}
|
|
2962
3110
|
}
|
|
2963
|
-
/** Send a narrowly extracted #648 reply; operator notification is a separate event. */
|
|
2964
|
-
sendRecoveredMalformedReply(text, correlationId) {
|
|
2965
|
-
try {
|
|
2966
|
-
this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
|
|
2967
|
-
}
|
|
2968
|
-
catch (err) {
|
|
2969
|
-
this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
3111
|
/** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
|
|
2973
|
-
deliverDaemonReply(body, requestPrefix, logLabel) {
|
|
3112
|
+
deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
|
|
2974
3113
|
const args = { text: body };
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
3114
|
+
const chatId = target?.chatId ?? this.lastChatId;
|
|
3115
|
+
const threadId = target?.threadId ?? this.lastThreadId;
|
|
3116
|
+
const adapterId = target?.adapterId ?? this.lastAdapterId;
|
|
3117
|
+
if (chatId) {
|
|
3118
|
+
args.chat_id = chatId;
|
|
3119
|
+
if (threadId)
|
|
3120
|
+
args.thread_id = threadId;
|
|
2979
3121
|
}
|
|
2980
3122
|
const adapters = this.messageBus.getAllAdapters();
|
|
2981
3123
|
if (adapters.length > 0) {
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
3124
|
+
// With one adapter there is nothing to choose and adapters[0] is exact.
|
|
3125
|
+
// With several, "no adapter named" is a routing failure, not a default:
|
|
3126
|
+
// picking the first sends one world's chat id through another world's bot.
|
|
3127
|
+
const adapter = adapterId
|
|
3128
|
+
? this.messageBus.getAdapter(adapterId)
|
|
3129
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
3130
|
+
if (!adapter) {
|
|
3131
|
+
this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
|
|
3132
|
+
return Promise.resolve(false);
|
|
3133
|
+
}
|
|
3134
|
+
return new Promise(resolve => {
|
|
3135
|
+
let settled = false;
|
|
3136
|
+
const finish = (delivered, error) => {
|
|
3137
|
+
if (settled)
|
|
3138
|
+
return;
|
|
3139
|
+
settled = true;
|
|
3140
|
+
clearTimeout(timeout);
|
|
3141
|
+
if (delivered)
|
|
3142
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3143
|
+
else
|
|
3144
|
+
this.logger.error({ error }, `${logLabel} failed`);
|
|
3145
|
+
resolve(delivered);
|
|
3146
|
+
};
|
|
3147
|
+
const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
|
|
3148
|
+
timeout.unref?.();
|
|
3149
|
+
try {
|
|
3150
|
+
if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
|
|
3151
|
+
finish(false, "reply route unavailable");
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
catch {
|
|
3155
|
+
finish(false, "adapter reply threw before returning a promise");
|
|
3156
|
+
}
|
|
2987
3157
|
});
|
|
2988
|
-
return;
|
|
2989
3158
|
}
|
|
2990
3159
|
if (!this.ipcServer) {
|
|
2991
3160
|
this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
|
|
2992
|
-
return;
|
|
3161
|
+
return Promise.resolve(false);
|
|
2993
3162
|
}
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3163
|
+
return new Promise(resolve => {
|
|
3164
|
+
const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
|
|
3165
|
+
const timeout = setTimeout(() => {
|
|
3166
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3167
|
+
this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
|
|
3168
|
+
resolve(false);
|
|
3169
|
+
}, daemonBudgetMs("reply"));
|
|
3170
|
+
timeout.unref?.();
|
|
3171
|
+
// Register before broadcast: an in-process/fast adapter can answer in the
|
|
3172
|
+
// same tick, and a waiter installed afterwards would miss that response.
|
|
3173
|
+
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
3174
|
+
clearTimeout(timeout);
|
|
3175
|
+
const delivered = !respMsg.error && respMsg.result != null;
|
|
3176
|
+
if (!delivered)
|
|
3177
|
+
this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
|
|
3178
|
+
else
|
|
3179
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3180
|
+
resolve(delivered);
|
|
3181
|
+
});
|
|
3182
|
+
try {
|
|
3183
|
+
this.ipcServer.broadcast({
|
|
3184
|
+
type: "fleet_outbound",
|
|
3185
|
+
tool: "reply",
|
|
3186
|
+
args,
|
|
3187
|
+
fleetRequestId: fleetReqId,
|
|
3188
|
+
adapterId,
|
|
3189
|
+
statusOnly,
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
catch {
|
|
3193
|
+
clearTimeout(timeout);
|
|
3194
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3195
|
+
this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
|
|
3196
|
+
resolve(false);
|
|
3197
|
+
}
|
|
3015
3198
|
});
|
|
3016
3199
|
}
|
|
3017
3200
|
clearInstanceStateIdleTimer() {
|
|
@@ -3668,8 +3851,7 @@ export class Daemon extends EventEmitter {
|
|
|
3668
3851
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
3669
3852
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3670
3853
|
return;
|
|
3671
|
-
|
|
3672
|
-
this.markTurnStarted(meta, rawText);
|
|
3854
|
+
await this.deliverMessage(rawText, undefined, { deliveryEpoch });
|
|
3673
3855
|
}).catch(err => {
|
|
3674
3856
|
this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
|
|
3675
3857
|
});
|
|
@@ -4841,14 +5023,21 @@ export class Daemon extends EventEmitter {
|
|
|
4841
5023
|
if (this.socketSessionNames.get(socket) === this.name) {
|
|
4842
5024
|
this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
|
|
4843
5025
|
}
|
|
5026
|
+
// A tool invocation is not delivery evidence. Capture which obligation it
|
|
5027
|
+
// belongs to now, then settle it only after the adapter/fleet responds.
|
|
5028
|
+
// Explicitly mapped sibling sessions must not satisfy this instance's turn;
|
|
5029
|
+
// an unmapped socket is retained for legacy/tests where mcp_ready was not
|
|
5030
|
+
// observed before the first tool call.
|
|
5031
|
+
const sourceSession = this.socketSessionNames.get(socket);
|
|
5032
|
+
const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
|
|
5033
|
+
? this.turnReplyGuard.beginToolAttempt(tool === "reply")
|
|
5034
|
+
: null;
|
|
4844
5035
|
// For now, log and respond. Full adapter routing will be wired in fleet manager.
|
|
4845
5036
|
const respond = (result, error) => {
|
|
4846
5037
|
// A message that verifiably went out stands down the dead-MCP proxy reply
|
|
4847
5038
|
// for this turn: the agent proved it can still speak for itself.
|
|
4848
5039
|
if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
|
|
4849
|
-
this.
|
|
4850
|
-
if (tool === "reply")
|
|
4851
|
-
this.turnReplyDelivered = true;
|
|
5040
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
4852
5041
|
}
|
|
4853
5042
|
const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
|
|
4854
5043
|
if (!sent) {
|
|
@@ -5039,7 +5228,15 @@ export class Daemon extends EventEmitter {
|
|
|
5039
5228
|
});
|
|
5040
5229
|
return;
|
|
5041
5230
|
}
|
|
5042
|
-
|
|
5231
|
+
// Same rule as deliverDaemonReply: one adapter is unambiguous, several with
|
|
5232
|
+
// no bound world is a routing failure rather than a first-wins guess.
|
|
5233
|
+
const adapter = this.lastAdapterId
|
|
5234
|
+
? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
|
|
5235
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
5236
|
+
if (!adapter) {
|
|
5237
|
+
respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
|
|
5238
|
+
return;
|
|
5239
|
+
}
|
|
5043
5240
|
if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
|
|
5044
5241
|
respond(null, `Unknown tool: ${tool}`);
|
|
5045
5242
|
}
|
|
@@ -5255,6 +5452,11 @@ export class Daemon extends EventEmitter {
|
|
|
5255
5452
|
* into exactly the window this exists to close.
|
|
5256
5453
|
*/
|
|
5257
5454
|
beginSpawn() {
|
|
5455
|
+
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
5456
|
+
// v1 deliberately does not persist obligations across restarts: missing one
|
|
5457
|
+
// warning is safer than treating the replacement's startup idle as the old
|
|
5458
|
+
// turn ending and injecting a stale recovery prompt.
|
|
5459
|
+
this.turnReplyGuard.reset();
|
|
5258
5460
|
this.spawnDepth++;
|
|
5259
5461
|
this.spawning = true;
|
|
5260
5462
|
if (!this.spawnSettled) {
|
|
@@ -6018,12 +6220,21 @@ export class Daemon extends EventEmitter {
|
|
|
6018
6220
|
updateLastChat(chatId, threadId, adapterId) {
|
|
6019
6221
|
if (!chatId)
|
|
6020
6222
|
return;
|
|
6223
|
+
const chatChanged = this.lastChatId !== chatId;
|
|
6021
6224
|
this.lastChatId = chatId;
|
|
6022
6225
|
// An unthreaded inbound must clear a previous topic rather than leaking it
|
|
6023
6226
|
// into the next reply target.
|
|
6024
6227
|
this.lastThreadId = threadId || undefined;
|
|
6025
6228
|
if (adapterId)
|
|
6026
6229
|
this.lastAdapterId = adapterId;
|
|
6230
|
+
else if (chatChanged) {
|
|
6231
|
+
// A NEW chat with no adapter named alongside it: the old adapter belonged
|
|
6232
|
+
// to the old chat, and keeping it asserts a pairing nobody supplied. That
|
|
6233
|
+
// is how one platform's chat id ends up being sent through another
|
|
6234
|
+
// platform's bot. Unknown is recoverable (a single-adapter fleet is
|
|
6235
|
+
// unambiguous anyway); a confidently wrong world is not.
|
|
6236
|
+
this.lastAdapterId = undefined;
|
|
6237
|
+
}
|
|
6027
6238
|
try {
|
|
6028
6239
|
writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
|
|
6029
6240
|
}
|