@songsid/agend 2.1.5-beta.13 → 2.1.5-beta.15
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/kiro.js +24 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +25 -9
- package/dist/daemon.js +312 -96
- package/dist/daemon.js.map +1 -1
- package/dist/deadline.d.ts +23 -0
- package/dist/deadline.js +29 -0
- package/dist/deadline.js.map +1 -0
- package/dist/fleet-manager.d.ts +23 -0
- package/dist/fleet-manager.js +78 -14
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +5 -0
- package/dist/instance-lifecycle.js +42 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +18 -2
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +31 -4
- package/dist/login-controller.js +52 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +29 -0
- package/dist/login-flows.js +48 -1
- package/dist/login-flows.js.map +1 -1
- package/dist/restart-progress.js +3 -21
- package/dist/restart-progress.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
|
/**
|
|
@@ -1071,6 +1071,33 @@ export class Daemon extends EventEmitter {
|
|
|
1071
1071
|
static errorPatternKey(ep) {
|
|
1072
1072
|
return `${ep.type}:${ep.pattern.source}`;
|
|
1073
1073
|
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Undo the auth suspicion a pattern match armed, once the backend's token-free
|
|
1076
|
+
* probe has said the credentials are fine.
|
|
1077
|
+
*
|
|
1078
|
+
* emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
|
|
1079
|
+
* the lifecycle's "valid" verdict used to just drop the incident — leaving the
|
|
1080
|
+
* daemon permanently suspicious of an auth failure that never existed. That
|
|
1081
|
+
* state is not inert: authFailureUnresolved suppresses the stuck/hang
|
|
1082
|
+
* notification and holds MCP auto-restart (a later real MCP death is then read
|
|
1083
|
+
* as already-confirmed auth trouble rather than re-verified), and the recovery
|
|
1084
|
+
* gate suppresses further error detection until a ready pattern shows up.
|
|
1085
|
+
*
|
|
1086
|
+
* Only what an auth match armed is rolled back. If some other pattern has
|
|
1087
|
+
* since armed the recovery gate, that one is still live and stays.
|
|
1088
|
+
*/
|
|
1089
|
+
clearSuspectedAuthFailure() {
|
|
1090
|
+
if (!this.authFailureUnresolved && !this.loginScreenReported)
|
|
1091
|
+
return false;
|
|
1092
|
+
this.authFailureUnresolved = false;
|
|
1093
|
+
this.loginScreenReported = false;
|
|
1094
|
+
if (this.lastDetectedErrorType === "auth_error") {
|
|
1095
|
+
this.clearErrorRecoveryGate();
|
|
1096
|
+
this.lastDetectedErrorType = null;
|
|
1097
|
+
}
|
|
1098
|
+
this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1074
1101
|
clearErrorRecoveryGate() {
|
|
1075
1102
|
this.errorWaitingForRecovery = false;
|
|
1076
1103
|
this.errorDetectedAt = 0;
|
|
@@ -2492,6 +2519,7 @@ export class Daemon extends EventEmitter {
|
|
|
2492
2519
|
}
|
|
2493
2520
|
async stop() {
|
|
2494
2521
|
this.logger.info("Stopping daemon instance");
|
|
2522
|
+
this.turnReplyGuard.reset();
|
|
2495
2523
|
this.freezeRuntimeMonitors();
|
|
2496
2524
|
this.pendingIpcRequests.clear();
|
|
2497
2525
|
if (this.adapter)
|
|
@@ -2785,7 +2813,7 @@ export class Daemon extends EventEmitter {
|
|
|
2785
2813
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
2786
2814
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
2787
2815
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
2788
|
-
this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2816
|
+
const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2789
2817
|
// The turn is over. A transcript can end on a tool_use with no matching
|
|
2790
2818
|
// tool_result (interrupted, crashed, cancelled), which would otherwise leave
|
|
2791
2819
|
// the last tool pinned to the progress line for the rest of the session.
|
|
@@ -2793,7 +2821,8 @@ export class Daemon extends EventEmitter {
|
|
|
2793
2821
|
this.resetToolProgress();
|
|
2794
2822
|
// Must run before the mcpRestartPending branch below: the pane text is the
|
|
2795
2823
|
// only copy of the answer, and the revival restart is about to clear it.
|
|
2796
|
-
|
|
2824
|
+
if (acceptedIdle)
|
|
2825
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
2797
2826
|
}
|
|
2798
2827
|
if (snapshot.state !== previous) {
|
|
2799
2828
|
this.logger.info({
|
|
@@ -2844,13 +2873,17 @@ export class Daemon extends EventEmitter {
|
|
|
2844
2873
|
// a task result, and a stale one (sol's review of #515).
|
|
2845
2874
|
if (meta.from_instance || !meta.chat_id)
|
|
2846
2875
|
return;
|
|
2847
|
-
this.turnHadInbound = true;
|
|
2848
|
-
this.turnOutboundDelivered = false;
|
|
2849
|
-
this.turnReplyDelivered = false;
|
|
2850
|
-
this.turnCorrelationId = meta.correlation_id || undefined;
|
|
2851
2876
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
2852
2877
|
// is the agent's own output.
|
|
2853
|
-
|
|
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
|
+
});
|
|
2854
2887
|
}
|
|
2855
2888
|
/**
|
|
2856
2889
|
* Turn ended (busy→idle edge): if the MCP server is dead and none of the
|
|
@@ -2860,56 +2893,197 @@ export class Daemon extends EventEmitter {
|
|
|
2860
2893
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
2861
2894
|
*/
|
|
2862
2895
|
maybeProxyReplyOnTurnEnd(pane) {
|
|
2863
|
-
const
|
|
2864
|
-
|
|
2865
|
-
const replyDelivered = this.turnReplyDelivered;
|
|
2866
|
-
const correlationId = this.turnCorrelationId;
|
|
2867
|
-
const inboundMarker = this.turnInboundMarker;
|
|
2868
|
-
this.turnHadInbound = false;
|
|
2869
|
-
this.turnOutboundDelivered = false;
|
|
2870
|
-
this.turnReplyDelivered = false;
|
|
2871
|
-
this.turnCorrelationId = undefined;
|
|
2872
|
-
this.turnInboundMarker = undefined;
|
|
2873
|
-
if (!hadInbound || this.isPaused)
|
|
2896
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
2897
|
+
if (!turn || this.isPaused)
|
|
2874
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);
|
|
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
|
+
}
|
|
2875
2917
|
// Claude sometimes prints a broken XML tool call as plain text and returns
|
|
2876
2918
|
// idle without ever invoking `reply`. This is independent of MCP liveness:
|
|
2877
2919
|
// the model malformed the call before the server could receive it.
|
|
2878
|
-
if (
|
|
2879
|
-
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
|
|
2920
|
+
if (this.isClaudeCodeBackend() && pane) {
|
|
2921
|
+
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
|
|
2880
2922
|
if (malformed) {
|
|
2881
2923
|
const stale = malformed.signature === this.lastMalformedToolCallSignature;
|
|
2882
2924
|
this.lastMalformedToolCallSignature = malformed.signature;
|
|
2883
2925
|
if (!stale) {
|
|
2884
|
-
|
|
2885
|
-
this.logger.warn({ correlationId, recovered }, recovered
|
|
2926
|
+
this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
|
|
2886
2927
|
? "Malformed Claude tool call detected — attempting to recover reply text"
|
|
2887
2928
|
: "Malformed Claude tool call detected — reply text could not be extracted");
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
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);
|
|
2891
2952
|
}
|
|
2892
2953
|
// Never let the broader dead-MCP fallback relay the XML/chrome too.
|
|
2893
2954
|
return;
|
|
2894
2955
|
}
|
|
2895
2956
|
}
|
|
2896
|
-
|
|
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);
|
|
2897
2964
|
return;
|
|
2898
|
-
|
|
2899
|
-
if (this.
|
|
2965
|
+
}
|
|
2966
|
+
if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
|
|
2967
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2900
2968
|
return;
|
|
2901
|
-
|
|
2902
|
-
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");
|
|
2903
2975
|
return;
|
|
2904
|
-
|
|
2976
|
+
}
|
|
2977
|
+
this.startReplyRecovery(turn, "no_valid_call");
|
|
2905
2978
|
}
|
|
2906
2979
|
isClaudeCodeBackend() {
|
|
2907
2980
|
return this.runtimeIdentity?.backend === "claude-code"
|
|
2908
2981
|
|| this.config.backend === "claude-code"
|
|
2909
2982
|
|| this.backend?.binaryName === "claude";
|
|
2910
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
|
+
}
|
|
2911
3085
|
/** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
|
|
2912
|
-
async sendProxyReply(pane,
|
|
3086
|
+
async sendProxyReply(pane, target) {
|
|
2913
3087
|
try {
|
|
2914
3088
|
// The idle capture normally hands its pane in; fall back only when the
|
|
2915
3089
|
// edge came from a path without one.
|
|
@@ -2917,74 +3091,105 @@ export class Daemon extends EventEmitter {
|
|
|
2917
3091
|
pane = await this.tmux?.capturePane();
|
|
2918
3092
|
if (!pane)
|
|
2919
3093
|
return;
|
|
2920
|
-
const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
3094
|
+
const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
2921
3095
|
if (!text) {
|
|
2922
3096
|
this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
|
|
2923
3097
|
return;
|
|
2924
3098
|
}
|
|
2925
3099
|
let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
|
|
2926
|
-
if (correlationId)
|
|
2927
|
-
body += `\n\n(correlation_id: ${correlationId})`;
|
|
2928
|
-
this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply —
|
|
2929
|
-
this.
|
|
2930
|
-
|
|
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 });
|
|
2931
3106
|
}
|
|
2932
3107
|
catch (err) {
|
|
2933
3108
|
this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
|
|
2934
3109
|
}
|
|
2935
3110
|
}
|
|
2936
|
-
/** Send a narrowly extracted #648 reply; operator notification is a separate event. */
|
|
2937
|
-
sendRecoveredMalformedReply(text, correlationId) {
|
|
2938
|
-
try {
|
|
2939
|
-
this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
|
|
2940
|
-
}
|
|
2941
|
-
catch (err) {
|
|
2942
|
-
this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
|
|
2943
|
-
}
|
|
2944
|
-
}
|
|
2945
3111
|
/** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
|
|
2946
|
-
deliverDaemonReply(body, requestPrefix, logLabel) {
|
|
3112
|
+
deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
|
|
2947
3113
|
const args = { text: body };
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
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;
|
|
2952
3121
|
}
|
|
2953
3122
|
const adapters = this.messageBus.getAllAdapters();
|
|
2954
3123
|
if (adapters.length > 0) {
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3124
|
+
const adapter = adapterId ? this.messageBus.getAdapter(adapterId) : adapters[0];
|
|
3125
|
+
if (!adapter) {
|
|
3126
|
+
this.logger.error({ adapterId }, `${logLabel} failed — target adapter is unavailable`);
|
|
3127
|
+
return Promise.resolve(false);
|
|
3128
|
+
}
|
|
3129
|
+
return new Promise(resolve => {
|
|
3130
|
+
let settled = false;
|
|
3131
|
+
const finish = (delivered, error) => {
|
|
3132
|
+
if (settled)
|
|
3133
|
+
return;
|
|
3134
|
+
settled = true;
|
|
3135
|
+
clearTimeout(timeout);
|
|
3136
|
+
if (delivered)
|
|
3137
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3138
|
+
else
|
|
3139
|
+
this.logger.error({ error }, `${logLabel} failed`);
|
|
3140
|
+
resolve(delivered);
|
|
3141
|
+
};
|
|
3142
|
+
const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
|
|
3143
|
+
timeout.unref?.();
|
|
3144
|
+
try {
|
|
3145
|
+
if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
|
|
3146
|
+
finish(false, "reply route unavailable");
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
catch {
|
|
3150
|
+
finish(false, "adapter reply threw before returning a promise");
|
|
3151
|
+
}
|
|
2960
3152
|
});
|
|
2961
|
-
return;
|
|
2962
3153
|
}
|
|
2963
3154
|
if (!this.ipcServer) {
|
|
2964
3155
|
this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
|
|
2965
|
-
return;
|
|
3156
|
+
return Promise.resolve(false);
|
|
2966
3157
|
}
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
3158
|
+
return new Promise(resolve => {
|
|
3159
|
+
const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
|
|
3160
|
+
const timeout = setTimeout(() => {
|
|
3161
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3162
|
+
this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
|
|
3163
|
+
resolve(false);
|
|
3164
|
+
}, daemonBudgetMs("reply"));
|
|
3165
|
+
timeout.unref?.();
|
|
3166
|
+
// Register before broadcast: an in-process/fast adapter can answer in the
|
|
3167
|
+
// same tick, and a waiter installed afterwards would miss that response.
|
|
3168
|
+
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
3169
|
+
clearTimeout(timeout);
|
|
3170
|
+
const delivered = !respMsg.error && respMsg.result != null;
|
|
3171
|
+
if (!delivered)
|
|
3172
|
+
this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
|
|
3173
|
+
else
|
|
3174
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3175
|
+
resolve(delivered);
|
|
3176
|
+
});
|
|
3177
|
+
try {
|
|
3178
|
+
this.ipcServer.broadcast({
|
|
3179
|
+
type: "fleet_outbound",
|
|
3180
|
+
tool: "reply",
|
|
3181
|
+
args,
|
|
3182
|
+
fleetRequestId: fleetReqId,
|
|
3183
|
+
adapterId,
|
|
3184
|
+
statusOnly,
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
catch {
|
|
3188
|
+
clearTimeout(timeout);
|
|
3189
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3190
|
+
this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
|
|
3191
|
+
resolve(false);
|
|
3192
|
+
}
|
|
2988
3193
|
});
|
|
2989
3194
|
}
|
|
2990
3195
|
clearInstanceStateIdleTimer() {
|
|
@@ -3641,8 +3846,7 @@ export class Daemon extends EventEmitter {
|
|
|
3641
3846
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
3642
3847
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3643
3848
|
return;
|
|
3644
|
-
|
|
3645
|
-
this.markTurnStarted(meta, rawText);
|
|
3849
|
+
await this.deliverMessage(rawText, undefined, { deliveryEpoch });
|
|
3646
3850
|
}).catch(err => {
|
|
3647
3851
|
this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
|
|
3648
3852
|
});
|
|
@@ -4814,14 +5018,21 @@ export class Daemon extends EventEmitter {
|
|
|
4814
5018
|
if (this.socketSessionNames.get(socket) === this.name) {
|
|
4815
5019
|
this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
|
|
4816
5020
|
}
|
|
5021
|
+
// A tool invocation is not delivery evidence. Capture which obligation it
|
|
5022
|
+
// belongs to now, then settle it only after the adapter/fleet responds.
|
|
5023
|
+
// Explicitly mapped sibling sessions must not satisfy this instance's turn;
|
|
5024
|
+
// an unmapped socket is retained for legacy/tests where mcp_ready was not
|
|
5025
|
+
// observed before the first tool call.
|
|
5026
|
+
const sourceSession = this.socketSessionNames.get(socket);
|
|
5027
|
+
const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
|
|
5028
|
+
? this.turnReplyGuard.beginToolAttempt(tool === "reply")
|
|
5029
|
+
: null;
|
|
4817
5030
|
// For now, log and respond. Full adapter routing will be wired in fleet manager.
|
|
4818
5031
|
const respond = (result, error) => {
|
|
4819
5032
|
// A message that verifiably went out stands down the dead-MCP proxy reply
|
|
4820
5033
|
// for this turn: the agent proved it can still speak for itself.
|
|
4821
5034
|
if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
|
|
4822
|
-
this.
|
|
4823
|
-
if (tool === "reply")
|
|
4824
|
-
this.turnReplyDelivered = true;
|
|
5035
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
4825
5036
|
}
|
|
4826
5037
|
const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
|
|
4827
5038
|
if (!sent) {
|
|
@@ -5228,6 +5439,11 @@ export class Daemon extends EventEmitter {
|
|
|
5228
5439
|
* into exactly the window this exists to close.
|
|
5229
5440
|
*/
|
|
5230
5441
|
beginSpawn() {
|
|
5442
|
+
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
5443
|
+
// v1 deliberately does not persist obligations across restarts: missing one
|
|
5444
|
+
// warning is safer than treating the replacement's startup idle as the old
|
|
5445
|
+
// turn ending and injecting a stale recovery prompt.
|
|
5446
|
+
this.turnReplyGuard.reset();
|
|
5231
5447
|
this.spawnDepth++;
|
|
5232
5448
|
this.spawning = true;
|
|
5233
5449
|
if (!this.spawnSettled) {
|