@songsid/agend 2.1.7-beta.3 → 2.1.7-beta.5
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/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/agent-cli-instructions.md +5 -0
- package/dist/agent-cli.js +13 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +48 -5
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/codex-session-lookup.d.ts +59 -0
- package/dist/backend/codex-session-lookup.js +139 -0
- package/dist/backend/codex-session-lookup.js.map +1 -0
- package/dist/backend/codex.d.ts +29 -0
- package/dist/backend/codex.js +183 -10
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +21 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/mcp-operation-response.d.ts +3 -0
- package/dist/channel/mcp-operation-response.js +21 -0
- package/dist/channel/mcp-operation-response.js.map +1 -0
- package/dist/channel/mcp-operation-response.test.d.ts +1 -0
- package/dist/channel/mcp-operation-response.test.js +23 -0
- package/dist/channel/mcp-operation-response.test.js.map +1 -0
- package/dist/channel/mcp-server.js +20 -5
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +68 -2
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +34 -0
- package/dist/daemon.js +415 -41
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +174 -0
- package/dist/delivery-outbox.js +824 -0
- package/dist/delivery-outbox.js.map +1 -0
- package/dist/delivery-outbox.test.d.ts +1 -0
- package/dist/delivery-outbox.test.js +597 -0
- package/dist/delivery-outbox.test.js.map +1 -0
- package/dist/delivery-queue-evidence.d.ts +15 -0
- package/dist/delivery-queue-evidence.js +35 -0
- package/dist/delivery-queue-evidence.js.map +1 -0
- package/dist/delivery-queue-evidence.test.d.ts +1 -0
- package/dist/delivery-queue-evidence.test.js +23 -0
- package/dist/delivery-queue-evidence.test.js.map +1 -0
- package/dist/delivery-reconciliation.d.ts +49 -0
- package/dist/delivery-reconciliation.js +296 -0
- package/dist/delivery-reconciliation.js.map +1 -0
- package/dist/delivery-reconciliation.test.d.ts +1 -0
- package/dist/delivery-reconciliation.test.js +411 -0
- package/dist/delivery-reconciliation.test.js.map +1 -0
- package/dist/fleet-manager.d.ts +39 -1
- package/dist/fleet-manager.js +345 -50
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +18 -0
- package/dist/instance-lifecycle.js +116 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +3 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +25 -1
- package/dist/outbound-handlers.js +119 -8
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -0
- package/dist/outbound-schemas.js +14 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-write-lock.d.ts +2 -0
- package/dist/pane-write-lock.js +19 -0
- package/dist/pane-write-lock.js.map +1 -1
- package/dist/remote-credential-check.d.ts +39 -0
- package/dist/remote-credential-check.js +81 -0
- package/dist/remote-credential-check.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +5 -1
- package/dist/scheduler/scheduler.js +60 -20
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/scheduler.test.js +41 -2
- package/dist/scheduler/scheduler.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +1 -1
- package/dist/tmux-manager.d.ts +3 -3
- package/dist/tmux-manager.js +6 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.js +6 -6
- package/dist/tool-permissions.js.map +1 -1
- package/dist/transcript-monitor.d.ts +3 -1
- package/dist/transcript-monitor.js +15 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +9 -0
- package/dist/transcript-sources.js +14 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/usage/providers.d.ts +11 -6
- package/dist/usage/providers.js +17 -10
- package/dist/usage/providers.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join, dirname, basename, resolve } from "node:path";
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { rotateLogIfNeeded } from "./logger.js";
|
|
7
7
|
import { mcpServerState } from "./mcp-liveness.js";
|
|
@@ -30,10 +30,11 @@ import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignat
|
|
|
30
30
|
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
31
31
|
import { t } from "./locale.js";
|
|
32
32
|
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
33
|
+
import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
|
|
33
34
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
35
|
const __dirname = dirname(__filename);
|
|
35
36
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
36
|
-
const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "start_instance", "restart_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "describe_instance"]);
|
|
37
|
+
const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "broadcast", "list_instances", "start_instance", "restart_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "delivery_status", "describe_instance"]);
|
|
37
38
|
const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
|
|
38
39
|
const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]);
|
|
39
40
|
const TASK_TOOL = "task";
|
|
@@ -95,6 +96,8 @@ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
|
95
96
|
export const PERIODIC_REDRAW_PROBE_MS = 25;
|
|
96
97
|
/** Do not turn a streaming Codex turn into one capture-pane per burst. */
|
|
97
98
|
export const STRUCTURED_IDLE_PROBE_MS = 500;
|
|
99
|
+
/** How long an unrecognised-but-empty composer must stay unchanged before it counts as idle (#978). */
|
|
100
|
+
export const UNKNOWN_LAYOUT_STABLE_MS = 10_000;
|
|
98
101
|
/** A foreground server/port-forward should hand control back or be acknowledged. */
|
|
99
102
|
export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
|
|
100
103
|
const LAST_INBOUND_FILE = "last-inbound-at";
|
|
@@ -326,9 +329,12 @@ export class PaneStateMachine {
|
|
|
326
329
|
* the caller has them.
|
|
327
330
|
* @param opts.forceBusy the backend has positively identified a still-running
|
|
328
331
|
* foreground tool even though its TUI keeps an old ready footer visible.
|
|
332
|
+
* @param opts.forceReady the backend has positively proved an idle composer
|
|
333
|
+
* the ready pattern cannot read (an unknown footer layout); the busy
|
|
334
|
+
* pattern still vetoes it.
|
|
329
335
|
*/
|
|
330
336
|
observe(pane, now = Date.now(), opts = {}) {
|
|
331
|
-
const { settled = false, changeAt = now, forceBusy = false } = opts;
|
|
337
|
+
const { settled = false, changeAt = now, forceBusy = false, forceReady = false } = opts;
|
|
332
338
|
const paneHash = createHash("sha256").update(pane).digest("hex");
|
|
333
339
|
const firstObservation = this.lastPaneHash === null;
|
|
334
340
|
const paneChanged = this.lastPaneHash !== paneHash;
|
|
@@ -340,7 +346,9 @@ export class PaneStateMachine {
|
|
|
340
346
|
// Some TUIs keep their ready footer visible while a foreground tool owns
|
|
341
347
|
// stdin. Backends can positively identify that tool from the pane; in that
|
|
342
348
|
// case the old prompt must not clear pending work or retire Cancel.
|
|
343
|
-
|
|
349
|
+
// forceReady: the backend proved readiness itself (#978 stable unknown
|
|
350
|
+
// footer); a visible busy marker still vetoes it.
|
|
351
|
+
const ready = !forceBusy && (forceReady ? !this.busyPattern?.test(pane) : this.isReady(pane));
|
|
344
352
|
const nextState = firstObservation
|
|
345
353
|
? ready ? "idle" : "working"
|
|
346
354
|
: paneChanged && !settled
|
|
@@ -908,6 +916,9 @@ export class Daemon extends EventEmitter {
|
|
|
908
916
|
spawnGate;
|
|
909
917
|
stormWindow;
|
|
910
918
|
backendOutage;
|
|
919
|
+
/** Identity of this live Daemon object; changes on object stop/restart. */
|
|
920
|
+
bootId = randomUUID();
|
|
921
|
+
deliveryOutbox;
|
|
911
922
|
logger;
|
|
912
923
|
tmuxSessionName;
|
|
913
924
|
tmux = null;
|
|
@@ -996,6 +1007,10 @@ export class Daemon extends EventEmitter {
|
|
|
996
1007
|
/** Consecutive structural idle captures for a noisy, periodically-redrawing
|
|
997
1008
|
* pane. Two are required before a working -> idle transition. */
|
|
998
1009
|
instanceStatePeriodicIdleConfirmations = 0;
|
|
1010
|
+
/** #978: last unrecognised-but-empty-composer capture and since when it has been unchanged. */
|
|
1011
|
+
unknownLayoutPaneKey = null;
|
|
1012
|
+
unknownLayoutStableSince = 0;
|
|
1013
|
+
unknownLayoutWarned = false;
|
|
999
1014
|
instanceStateStuckTimer = null;
|
|
1000
1015
|
instanceStateOutputListener = null;
|
|
1001
1016
|
instanceStateOutputEventName = null;
|
|
@@ -1081,6 +1096,7 @@ export class Daemon extends EventEmitter {
|
|
|
1081
1096
|
/** Mutual exclusion for *every* write into the pane, whichever subsystem it
|
|
1082
1097
|
* comes from. See PaneWriteLock for why interleaving is destructive. */
|
|
1083
1098
|
paneWriteLock = new PaneWriteLock();
|
|
1099
|
+
deliveryWritesStopping = false;
|
|
1084
1100
|
pendingInstructionsUpdate;
|
|
1085
1101
|
pendingInstructionsNotice = false;
|
|
1086
1102
|
// Whether the warmup steering-reload notice should be injected after spawn.
|
|
@@ -1313,6 +1329,109 @@ export class Daemon extends EventEmitter {
|
|
|
1313
1329
|
const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
|
|
1314
1330
|
this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
|
|
1315
1331
|
}
|
|
1332
|
+
/** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
|
|
1333
|
+
setDeliveryOutboxPort(port) {
|
|
1334
|
+
this.deliveryOutbox = port;
|
|
1335
|
+
}
|
|
1336
|
+
/** The fleet's other same-backend instances' working directories, read at spawn (#984). */
|
|
1337
|
+
peerWorkingDirectories;
|
|
1338
|
+
setPeerWorkingDirectories(peers) {
|
|
1339
|
+
this.peerWorkingDirectories = peers;
|
|
1340
|
+
}
|
|
1341
|
+
/** Fence pane writers before lifecycle captures pre-kill reconciliation evidence. */
|
|
1342
|
+
fenceDeliveryWritesForStop() {
|
|
1343
|
+
if (this.deliveryWritesStopping)
|
|
1344
|
+
return;
|
|
1345
|
+
this.deliveryWritesStopping = true;
|
|
1346
|
+
this.deliveryEpoch++;
|
|
1347
|
+
}
|
|
1348
|
+
/** Bounded drain: timeout remains safe because Enter has a write-ahead fence. */
|
|
1349
|
+
waitForDeliveryWritesToDrain(timeoutMs) {
|
|
1350
|
+
return this.paneWriteLock.waitForIdle(timeoutMs);
|
|
1351
|
+
}
|
|
1352
|
+
durableDeliveryAttempt(meta) {
|
|
1353
|
+
const deliveryId = meta.delivery_id;
|
|
1354
|
+
if (!deliveryId)
|
|
1355
|
+
return null;
|
|
1356
|
+
const attemptNo = Number(meta.delivery_attempt);
|
|
1357
|
+
if (!Number.isSafeInteger(attemptNo) || attemptNo < 1)
|
|
1358
|
+
return false;
|
|
1359
|
+
return { deliveryId, attemptNo };
|
|
1360
|
+
}
|
|
1361
|
+
beginDurableDelivery(delivery, evidence) {
|
|
1362
|
+
if (!this.deliveryOutbox) {
|
|
1363
|
+
this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason: "outbox port unavailable before pane write" });
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
const outcome = this.deliveryOutbox.begin(delivery.deliveryId, this.bootId, delivery.attemptNo, evidence);
|
|
1367
|
+
// A repeated begin is idempotent at the store but must not authorize a
|
|
1368
|
+
// second pane write. Only the first caller that acquired the permit acts.
|
|
1369
|
+
return outcome === "begun";
|
|
1370
|
+
}
|
|
1371
|
+
async durableAttemptEvidence(windowId, handingOffToNativeQueue, steer, rawPaste = false) {
|
|
1372
|
+
let checkpoint = null;
|
|
1373
|
+
try {
|
|
1374
|
+
checkpoint = await this.transcriptMonitor?.reconciliationCheckpoint() ?? null;
|
|
1375
|
+
}
|
|
1376
|
+
catch (err) {
|
|
1377
|
+
this.logger.debug({ err }, "Could not checkpoint transcript before durable delivery");
|
|
1378
|
+
}
|
|
1379
|
+
const submissionMode = rawPaste ? "raw_paste" : steer
|
|
1380
|
+
? "steer"
|
|
1381
|
+
: handingOffToNativeQueue ? "native_queue_handoff" : "idle_submit";
|
|
1382
|
+
const backend = this.config.backend ?? this.backend?.binaryName ?? "unknown";
|
|
1383
|
+
// The daemon does not synchronously probe CLI versions on the delivery
|
|
1384
|
+
// path. Until lifecycle supplies a version tied to this exact process,
|
|
1385
|
+
// queue handoffs persist an unknown resume contract and reconciliation
|
|
1386
|
+
// must fail closed rather than infer loss from transcript absence.
|
|
1387
|
+
const backendVersion = null;
|
|
1388
|
+
return {
|
|
1389
|
+
backend,
|
|
1390
|
+
backendVersion,
|
|
1391
|
+
windowId: windowId ?? this.tmux?.getWindowId() ?? null,
|
|
1392
|
+
transcriptPath: checkpoint?.path ?? null,
|
|
1393
|
+
transcriptOffset: checkpoint?.offset ?? null,
|
|
1394
|
+
transcriptSessionId: checkpoint?.sessionId ?? null,
|
|
1395
|
+
submissionMode,
|
|
1396
|
+
queueResumePolicy: queueResumePolicyForAttempt(backend, backendVersion, submissionMode),
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
abortDurableDelivery(delivery, reason) {
|
|
1400
|
+
if (!delivery)
|
|
1401
|
+
return;
|
|
1402
|
+
this.deliveryOutbox?.abort(delivery.deliveryId, this.bootId, delivery.attemptNo, reason);
|
|
1403
|
+
}
|
|
1404
|
+
retryDurableDeliveryBeforeBegin(delivery, reason) {
|
|
1405
|
+
if (!delivery)
|
|
1406
|
+
return;
|
|
1407
|
+
if (this.deliveryOutbox) {
|
|
1408
|
+
this.deliveryOutbox.retryBeforeBegin(delivery.deliveryId, this.bootId, delivery.attemptNo, reason, 5_000);
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
finishDurableDelivery(delivery, outcome, evidence) {
|
|
1415
|
+
if (!delivery)
|
|
1416
|
+
return;
|
|
1417
|
+
this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
|
|
1418
|
+
}
|
|
1419
|
+
finishDurableSubmission(delivery, verdict) {
|
|
1420
|
+
// Legacy backends may accept a pane write without enough pane evidence to
|
|
1421
|
+
// claim submission. Keep that row inspectable instead of recording a false
|
|
1422
|
+
// delivered state; reconciliation can classify it in a later phase.
|
|
1423
|
+
if (verdict.proof === "unverified") {
|
|
1424
|
+
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
|
|
1428
|
+
}
|
|
1429
|
+
deferDurableDelivery(meta, reason) {
|
|
1430
|
+
const delivery = this.durableDeliveryAttempt(meta);
|
|
1431
|
+
if (delivery === null || delivery === false)
|
|
1432
|
+
return;
|
|
1433
|
+
this.retryDurableDeliveryBeforeBegin(delivery, reason);
|
|
1434
|
+
}
|
|
1316
1435
|
async start() {
|
|
1317
1436
|
mkdirSync(this.instanceDir, { recursive: true });
|
|
1318
1437
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
@@ -1411,12 +1530,12 @@ export class Daemon extends EventEmitter {
|
|
|
1411
1530
|
void this.wake().then(() => {
|
|
1412
1531
|
this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
|
|
1413
1532
|
}).catch(err => {
|
|
1533
|
+
this.deferDurableDelivery(meta, `target wake failed before pane submission: ${err.message}`);
|
|
1414
1534
|
this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
|
|
1415
1535
|
});
|
|
1416
1536
|
}
|
|
1417
1537
|
else if (msg.type === "raw_paste") {
|
|
1418
|
-
|
|
1419
|
-
this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true);
|
|
1538
|
+
this.handleRawPasteIpcMessage(msg);
|
|
1420
1539
|
}
|
|
1421
1540
|
else if (msg.type === "config_update") {
|
|
1422
1541
|
this.applyConfigUpdate(msg.config);
|
|
@@ -2691,24 +2810,76 @@ export class Daemon extends EventEmitter {
|
|
|
2691
2810
|
isDeliveryEpochCurrent(epoch) {
|
|
2692
2811
|
return epoch === this.deliveryEpoch;
|
|
2693
2812
|
}
|
|
2694
|
-
queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false) {
|
|
2695
|
-
if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
2813
|
+
queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false, durableAttempt) {
|
|
2814
|
+
if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
2815
|
+
if (durableAttempt)
|
|
2816
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste target unavailable before pane queue");
|
|
2696
2817
|
return;
|
|
2818
|
+
}
|
|
2697
2819
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
2698
2820
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
2699
2821
|
this.logger.info("Pending raw delivery dropped by user cancel");
|
|
2822
|
+
if (durableAttempt)
|
|
2823
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while queued");
|
|
2700
2824
|
return;
|
|
2701
2825
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2826
|
+
const verdict = { reached: false };
|
|
2827
|
+
try {
|
|
2828
|
+
await this.wake();
|
|
2829
|
+
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
2830
|
+
if (durableAttempt)
|
|
2831
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while waking");
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
const delivered = await this.deliverMessage(rawText, undefined, {
|
|
2835
|
+
deliveryEpoch,
|
|
2836
|
+
verdict,
|
|
2837
|
+
durableAttempt,
|
|
2838
|
+
preserveExactBytes: true,
|
|
2839
|
+
});
|
|
2840
|
+
if (delivered)
|
|
2841
|
+
this.finishDurableSubmission(durableAttempt ?? null, verdict);
|
|
2842
|
+
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
2843
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "unverified"}`);
|
|
2844
|
+
}
|
|
2845
|
+
else if (durableAttempt && verdict.durableBeginCommitted) {
|
|
2846
|
+
this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "no-pane-write"}`);
|
|
2847
|
+
}
|
|
2848
|
+
else if (durableAttempt) {
|
|
2849
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "deferred"}`);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
catch (err) {
|
|
2853
|
+
if (durableAttempt && verdict.paneWriteStarted)
|
|
2854
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
|
|
2855
|
+
else if (durableAttempt && verdict.durableBeginCommitted)
|
|
2856
|
+
this.abortDurableDelivery(durableAttempt, err.message);
|
|
2857
|
+
else if (durableAttempt)
|
|
2858
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
|
|
2859
|
+
this.logger.warn({ err: err.message }, "raw_paste delivery error");
|
|
2704
2860
|
return;
|
|
2705
|
-
|
|
2861
|
+
}
|
|
2706
2862
|
if (confirmClear)
|
|
2707
2863
|
await this.confirmBackendClearDialog();
|
|
2708
2864
|
this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
|
|
2709
|
-
}).catch(err => {
|
|
2710
|
-
this.logger.warn({ err: err.message }, "raw_paste delivery error");
|
|
2711
2865
|
});
|
|
2866
|
+
this.pasteLock = this.pasteLock.catch(err => {
|
|
2867
|
+
this.logger.warn({ err: err.message }, "raw_paste delivery queue error");
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
/** Keep the IPC raw route's durable mode attached to the parsed attempt. */
|
|
2871
|
+
handleRawPasteIpcMessage(msg) {
|
|
2872
|
+
// Paste raw text directly to CLI without [user:] wrapping.
|
|
2873
|
+
const deliveryMeta = {
|
|
2874
|
+
delivery_id: typeof msg.delivery_id === "string" ? msg.delivery_id : "",
|
|
2875
|
+
delivery_attempt: typeof msg.delivery_attempt === "string" ? msg.delivery_attempt : "",
|
|
2876
|
+
};
|
|
2877
|
+
const durableAttempt = this.durableDeliveryAttempt(deliveryMeta);
|
|
2878
|
+
if (durableAttempt === false) {
|
|
2879
|
+
this.deferDurableDelivery(deliveryMeta, "invalid raw_paste delivery attempt metadata");
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true, durableAttempt ? { ...durableAttempt, submissionMode: "raw_paste" } : undefined);
|
|
2712
2883
|
}
|
|
2713
2884
|
/**
|
|
2714
2885
|
* Confirm a backend's terminal-level /clear prompt after the channel-level
|
|
@@ -2856,6 +3027,7 @@ export class Daemon extends EventEmitter {
|
|
|
2856
3027
|
}
|
|
2857
3028
|
async stop() {
|
|
2858
3029
|
this.logger.info("Stopping daemon instance");
|
|
3030
|
+
this.fenceDeliveryWritesForStop();
|
|
2859
3031
|
this.turnReplyGuard.reset();
|
|
2860
3032
|
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
2861
3033
|
// stopped. It must fail closed, not press Enter in a replacement pane.
|
|
@@ -3699,9 +3871,39 @@ export class Daemon extends EventEmitter {
|
|
|
3699
3871
|
}
|
|
3700
3872
|
else {
|
|
3701
3873
|
// A live busy marker or an unfamiliar layout outranks the broad Codex
|
|
3702
|
-
// ready pattern during the noisy-redraw path. Unknown is working
|
|
3874
|
+
// ready pattern during the noisy-redraw path. Unknown is working —
|
|
3875
|
+
// but not forever (#978): a footer the proof does not recognise
|
|
3876
|
+
// would otherwise pin an idle CLI in `working` and queue every
|
|
3877
|
+
// delivery indefinitely. If the backend still sees its own live,
|
|
3878
|
+
// empty composer and the pane has not changed (cosmetic glyphs
|
|
3879
|
+
// aside) for UNKNOWN_LAYOUT_STABLE_MS, read it as settled and ready.
|
|
3703
3880
|
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
3704
|
-
|
|
3881
|
+
const stableFallback = this.backend?.isStableUnknownLayoutIdlePane;
|
|
3882
|
+
const now = Date.now();
|
|
3883
|
+
if (stableFallback?.call(this.backend, pane)) {
|
|
3884
|
+
const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
|
|
3885
|
+
if (key !== this.unknownLayoutPaneKey) {
|
|
3886
|
+
this.unknownLayoutPaneKey = key;
|
|
3887
|
+
this.unknownLayoutStableSince = now;
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
else {
|
|
3891
|
+
this.unknownLayoutPaneKey = null;
|
|
3892
|
+
}
|
|
3893
|
+
if (this.unknownLayoutPaneKey !== null && now - this.unknownLayoutStableSince >= UNKNOWN_LAYOUT_STABLE_MS) {
|
|
3894
|
+
if (!this.unknownLayoutWarned) {
|
|
3895
|
+
this.unknownLayoutWarned = true;
|
|
3896
|
+
this.logger.warn({ backend: this.backend?.binaryName }, "Unrecognised idle footer layout; falling back to a stable empty composer for readiness");
|
|
3897
|
+
}
|
|
3898
|
+
snapshot = this.instanceStateMachine.observe(pane, now, {
|
|
3899
|
+
settled: true,
|
|
3900
|
+
changeAt: observedChangeAt,
|
|
3901
|
+
forceReady: true,
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
else {
|
|
3905
|
+
snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
|
|
3906
|
+
}
|
|
3705
3907
|
}
|
|
3706
3908
|
}
|
|
3707
3909
|
else if (structuredPeriodicIdle && reason !== "idle_debounce" && !settled) {
|
|
@@ -4182,6 +4384,9 @@ export class Daemon extends EventEmitter {
|
|
|
4182
4384
|
if (meta.reply_to_text) {
|
|
4183
4385
|
formatted += `\n(reply_to: "${meta.reply_to_text}")`;
|
|
4184
4386
|
}
|
|
4387
|
+
if (meta.delivery_id) {
|
|
4388
|
+
formatted = `[agend-delivery-id:${meta.delivery_id}]\n${formatted}`;
|
|
4389
|
+
}
|
|
4185
4390
|
return formatted;
|
|
4186
4391
|
}
|
|
4187
4392
|
/**
|
|
@@ -4204,7 +4409,10 @@ export class Daemon extends EventEmitter {
|
|
|
4204
4409
|
verdict.reached = true;
|
|
4205
4410
|
verdict.phase = phase;
|
|
4206
4411
|
verdict.proof = proof;
|
|
4207
|
-
|
|
4412
|
+
// Durable cross-instance rows are still retryable before a pane write. Their
|
|
4413
|
+
// terminal state is announced by the outbox only after its retry/TTL policy
|
|
4414
|
+
// is exhausted; emitting ❌ here would expose a transient gate timeout.
|
|
4415
|
+
if (status && !verdict.durableAttempt)
|
|
4208
4416
|
this.emit("message_failed", status); // ❌
|
|
4209
4417
|
return false;
|
|
4210
4418
|
}
|
|
@@ -4240,8 +4448,10 @@ export class Daemon extends EventEmitter {
|
|
|
4240
4448
|
* normal inbound message, with a steering notice prepended for context.
|
|
4241
4449
|
*/
|
|
4242
4450
|
steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
|
|
4243
|
-
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
4451
|
+
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4452
|
+
this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
|
|
4244
4453
|
return;
|
|
4454
|
+
}
|
|
4245
4455
|
// The fleet facade prevents a steer from overtaking an item still in its
|
|
4246
4456
|
// idle queue. Close the second race window here: an earlier item may already
|
|
4247
4457
|
// have reached this daemon's paste queue by the time the steer IPC arrives.
|
|
@@ -4256,25 +4466,60 @@ export class Daemon extends EventEmitter {
|
|
|
4256
4466
|
const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
|
|
4257
4467
|
+ this.formatInboundMessage(content, meta);
|
|
4258
4468
|
const status = channelStatus(meta);
|
|
4469
|
+
const durableAttempt = this.durableDeliveryAttempt(meta);
|
|
4470
|
+
if (durableAttempt === false) {
|
|
4471
|
+
this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
|
|
4472
|
+
return;
|
|
4473
|
+
}
|
|
4259
4474
|
this.steerLock = this.steerLock.then(async () => {
|
|
4260
|
-
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
4475
|
+
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4476
|
+
this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
|
|
4261
4477
|
return;
|
|
4478
|
+
}
|
|
4262
4479
|
await this.wake();
|
|
4263
|
-
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
4480
|
+
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4481
|
+
this.deferDurableDelivery(meta, "delivery epoch changed while waking for steer");
|
|
4264
4482
|
return;
|
|
4483
|
+
}
|
|
4265
4484
|
const verdict = { reached: false };
|
|
4266
|
-
|
|
4267
|
-
this.
|
|
4485
|
+
try {
|
|
4486
|
+
if (await this.deliverMessage(formatted, status, {
|
|
4487
|
+
steer: true,
|
|
4488
|
+
deliveryEpoch,
|
|
4489
|
+
submissionId: meta.message_id,
|
|
4490
|
+
verdict,
|
|
4491
|
+
durableAttempt: durableAttempt || undefined,
|
|
4492
|
+
})) {
|
|
4493
|
+
this.finishDurableSubmission(durableAttempt, verdict);
|
|
4494
|
+
this.markTurnStarted(meta, formatted);
|
|
4495
|
+
}
|
|
4496
|
+
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
4497
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
4498
|
+
}
|
|
4499
|
+
else if (durableAttempt && verdict.durableBeginCommitted) {
|
|
4500
|
+
this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
|
|
4501
|
+
}
|
|
4502
|
+
else if (durableAttempt) {
|
|
4503
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "deferred"}`);
|
|
4504
|
+
}
|
|
4505
|
+
else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4506
|
+
// Legacy non-outbox path retains its existing failure surface.
|
|
4507
|
+
this.reportCrossInstanceDeliveryFailure(meta, verdict);
|
|
4508
|
+
}
|
|
4268
4509
|
}
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4510
|
+
catch (err) {
|
|
4511
|
+
if (durableAttempt && verdict.paneWriteStarted)
|
|
4512
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
|
|
4513
|
+
else if (durableAttempt && verdict.durableBeginCommitted)
|
|
4514
|
+
this.abortDurableDelivery(durableAttempt, err.message);
|
|
4515
|
+
else if (durableAttempt)
|
|
4516
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
|
|
4517
|
+
throw err;
|
|
4274
4518
|
}
|
|
4275
4519
|
}).catch(err => {
|
|
4520
|
+
this.deferDurableDelivery(meta, `target wake/steer failed before pane submission: ${err.message}`);
|
|
4276
4521
|
this.logger.warn({ err: err.message }, "steer delivery error");
|
|
4277
|
-
if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4522
|
+
if (!durableAttempt && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4278
4523
|
this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
|
|
4279
4524
|
}
|
|
4280
4525
|
});
|
|
@@ -4315,10 +4560,12 @@ export class Daemon extends EventEmitter {
|
|
|
4315
4560
|
pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
|
|
4316
4561
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4317
4562
|
this.logger.info("Pending channel delivery dropped by user cancel");
|
|
4563
|
+
this.deferDurableDelivery(meta, "delivery epoch changed before queue admission");
|
|
4318
4564
|
return;
|
|
4319
4565
|
}
|
|
4320
4566
|
if (!this.tmux) {
|
|
4321
4567
|
this.logger.warn("Cannot push channel message: tmux not running");
|
|
4568
|
+
this.deferDurableDelivery(meta, "target tmux is unavailable");
|
|
4322
4569
|
return;
|
|
4323
4570
|
}
|
|
4324
4571
|
// Remember (and persist) the reply target. Only real channel messages have a
|
|
@@ -4375,9 +4622,12 @@ export class Daemon extends EventEmitter {
|
|
|
4375
4622
|
this.emit("message_queued", queuedStatus);
|
|
4376
4623
|
}
|
|
4377
4624
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
4625
|
+
let durable = null;
|
|
4626
|
+
let verdict = { reached: false };
|
|
4378
4627
|
try {
|
|
4379
4628
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4380
4629
|
this.logger.info("Pending channel delivery dropped by user cancel");
|
|
4630
|
+
this.deferDurableDelivery(meta, "delivery epoch changed while queued");
|
|
4381
4631
|
return;
|
|
4382
4632
|
}
|
|
4383
4633
|
if (this.config.pre_task_command) {
|
|
@@ -4395,18 +4645,60 @@ export class Daemon extends EventEmitter {
|
|
|
4395
4645
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
4396
4646
|
// the previous turn's tool list.
|
|
4397
4647
|
this.resetToolProgress();
|
|
4398
|
-
|
|
4399
|
-
|
|
4648
|
+
verdict = { reached: false };
|
|
4649
|
+
const durableAttempt = this.durableDeliveryAttempt(meta);
|
|
4650
|
+
if (durableAttempt === false) {
|
|
4651
|
+
this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
|
|
4652
|
+
return;
|
|
4653
|
+
}
|
|
4654
|
+
durable = durableAttempt;
|
|
4655
|
+
if (await this.deliverMessage(formatted, status, {
|
|
4656
|
+
deliveryEpoch,
|
|
4657
|
+
submissionId: meta.message_id,
|
|
4658
|
+
verdict,
|
|
4659
|
+
durableAttempt: durableAttempt || undefined,
|
|
4660
|
+
})) {
|
|
4661
|
+
this.finishDurableSubmission(durable, verdict);
|
|
4400
4662
|
this.markTurnStarted(meta, formatted);
|
|
4401
4663
|
}
|
|
4664
|
+
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
4665
|
+
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
4666
|
+
}
|
|
4667
|
+
else if (durableAttempt && verdict.durableBeginCommitted) {
|
|
4668
|
+
this.abortDurableDelivery(durable, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
|
|
4669
|
+
}
|
|
4670
|
+
else if (durableAttempt) {
|
|
4671
|
+
this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "deferred"}`);
|
|
4672
|
+
}
|
|
4402
4673
|
else if (meta.from_instance && verdict.reached
|
|
4403
4674
|
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4404
4675
|
// Only a delivery that reached a verdict is reported. A pane that is
|
|
4405
4676
|
// not ready yet, a cancel, a storm hold or a shutdown all return
|
|
4406
4677
|
// false without one, and telling the sender its message was lost
|
|
4407
4678
|
// there would be the false ❌ of #826 in its other form.
|
|
4679
|
+
this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
|
|
4408
4680
|
this.reportCrossInstanceDeliveryFailure(meta, verdict);
|
|
4409
4681
|
}
|
|
4682
|
+
else if (verdict.reached) {
|
|
4683
|
+
this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
|
|
4684
|
+
}
|
|
4685
|
+
else if (verdict.paneWriteStarted) {
|
|
4686
|
+
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
4687
|
+
}
|
|
4688
|
+
else {
|
|
4689
|
+
this.abortDurableDelivery(durable, "delivery did not reach pane submission");
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
catch (err) {
|
|
4693
|
+
if (durable && verdict.paneWriteStarted)
|
|
4694
|
+
this.finishDurableDelivery(durable, "uncertain", err.message);
|
|
4695
|
+
else if (durable && verdict.durableBeginCommitted)
|
|
4696
|
+
this.abortDurableDelivery(durable, err.message);
|
|
4697
|
+
else if (durable)
|
|
4698
|
+
this.retryDurableDeliveryBeforeBegin(durable, err.message);
|
|
4699
|
+
else
|
|
4700
|
+
throw err;
|
|
4701
|
+
this.logger.warn({ err: err.message }, "Durable pane delivery deferred after exception");
|
|
4410
4702
|
}
|
|
4411
4703
|
finally {
|
|
4412
4704
|
this.pasteQueueDepth--;
|
|
@@ -4439,13 +4731,15 @@ export class Daemon extends EventEmitter {
|
|
|
4439
4731
|
// The caller passes its own holder when it needs the answer; a system paste
|
|
4440
4732
|
// that ignores the outcome gets a throwaway.
|
|
4441
4733
|
const verdict = opts?.verdict ?? { reached: false };
|
|
4734
|
+
if (opts?.durableAttempt)
|
|
4735
|
+
verdict.durableAttempt = true;
|
|
4442
4736
|
const cancelled = () => opts?.deliveryEpoch !== undefined
|
|
4443
4737
|
&& !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
|
|
4444
4738
|
if (cancelled() || this.stormWindow?.isStopped())
|
|
4445
4739
|
return false;
|
|
4446
4740
|
// Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
|
|
4447
4741
|
const fenceCount = (formatted.match(/```/g) || []).length;
|
|
4448
|
-
if (fenceCount % 2 !== 0) {
|
|
4742
|
+
if (!opts?.preserveExactBytes && fenceCount % 2 !== 0) {
|
|
4449
4743
|
// Odd number of fences = unclosed. Remove all code fences from the message.
|
|
4450
4744
|
formatted = formatted.replace(/```/g, "");
|
|
4451
4745
|
}
|
|
@@ -4513,7 +4807,9 @@ export class Daemon extends EventEmitter {
|
|
|
4513
4807
|
// The pane never freed up. Report the failure instead of pasting into a
|
|
4514
4808
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
4515
4809
|
// would land on top of it) — and instead of holding the queue silently.
|
|
4516
|
-
this.logger.
|
|
4810
|
+
this.logger[verdict.durableAttempt ? "warn" : "error"](verdict.durableAttempt
|
|
4811
|
+
? "Pane still busy after the idle wait — durable delivery remains retryable"
|
|
4812
|
+
: "Pane still busy after the idle wait — reporting delivery failure");
|
|
4517
4813
|
return this.failDelivery(verdict, status, "readiness", "timeout-before-write");
|
|
4518
4814
|
}
|
|
4519
4815
|
}
|
|
@@ -4591,7 +4887,7 @@ export class Daemon extends EventEmitter {
|
|
|
4591
4887
|
}
|
|
4592
4888
|
if (!(await this.hasPositiveDeliveryInput()))
|
|
4593
4889
|
return "dialog";
|
|
4594
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
|
|
4890
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true);
|
|
4595
4891
|
});
|
|
4596
4892
|
if (outcome === "spawn-started") {
|
|
4597
4893
|
// The pane changed under this delivery: its queued paste must not land
|
|
@@ -5345,13 +5641,20 @@ export class Daemon extends EventEmitter {
|
|
|
5345
5641
|
* precisely so the lock's scope is visible at the call site rather than being
|
|
5346
5642
|
* an invariant maintained by comments.
|
|
5347
5643
|
*/
|
|
5348
|
-
async sendDeliveryEnter(phase, stillCurrent) {
|
|
5644
|
+
async sendDeliveryEnter(phase, stillCurrent, durableAttempt) {
|
|
5349
5645
|
if (!(await this.waitForInputTransientToClear(phase)))
|
|
5350
5646
|
return false;
|
|
5351
5647
|
// A recovery Enter may have waited for a transient while cancel or spawn
|
|
5352
5648
|
// replaced the delivery. Check at the last point before the tmux write.
|
|
5353
|
-
if (stillCurrent && !stillCurrent())
|
|
5649
|
+
if (this.deliveryWritesStopping || (stillCurrent && !stillCurrent()))
|
|
5650
|
+
return false;
|
|
5651
|
+
// W1: persist the "Enter may have happened" boundary before tmux receives
|
|
5652
|
+
// the key. A crash after this commit is never auto-replayed without a
|
|
5653
|
+
// positive transcript marker, even if the stale pane still shows composer.
|
|
5654
|
+
if (durableAttempt && !this.deliveryOutbox?.markEnterStarted(durableAttempt.deliveryId, this.bootId, durableAttempt.attemptNo)) {
|
|
5655
|
+
this.logger.error({ phase, deliveryId: durableAttempt.deliveryId }, "Could not commit durable Enter write-ahead fence");
|
|
5354
5656
|
return false;
|
|
5657
|
+
}
|
|
5355
5658
|
const sent = await this.tmux.sendSpecialKey("Enter");
|
|
5356
5659
|
if (!sent) {
|
|
5357
5660
|
this.logger.error({
|
|
@@ -5364,19 +5667,46 @@ export class Daemon extends EventEmitter {
|
|
|
5364
5667
|
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
5365
5668
|
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
5366
5669
|
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
5367
|
-
verdict = { reached: false }) {
|
|
5670
|
+
verdict = { reached: false }, durableAttempt, steer = false) {
|
|
5368
5671
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
5672
|
+
const rawPaste = durableAttempt?.submissionMode === "raw_paste";
|
|
5369
5673
|
let windowId = initialWindowId;
|
|
5370
5674
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
5371
5675
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
5372
5676
|
const maxAttempts = 3;
|
|
5677
|
+
let beginChecked = false;
|
|
5373
5678
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
5374
5679
|
const pasteStartedAt = Date.now();
|
|
5375
5680
|
// Read the pane BEFORE writing to it, so the submission check can require
|
|
5376
5681
|
// evidence this paste ADDED rather than evidence that was already there.
|
|
5377
5682
|
const pasteBaseline = await this.capturePaneEvidence(signature);
|
|
5683
|
+
// Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
|
|
5684
|
+
// Commit the submission fence at the last possible point before the
|
|
5685
|
+
// first side effect; a crash during those waits remains safely replayable.
|
|
5686
|
+
if (durableAttempt && !beginChecked) {
|
|
5687
|
+
beginChecked = true;
|
|
5688
|
+
const attemptEvidence = await this.durableAttemptEvidence(windowId, handingOffToNativeQueue, steer, durableAttempt.submissionMode === "raw_paste");
|
|
5689
|
+
if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
|
|
5690
|
+
verdict.durableBeginRejected = true;
|
|
5691
|
+
verdict.phase = "submission-begin";
|
|
5692
|
+
verdict.proof = "permit-rejected";
|
|
5693
|
+
return false;
|
|
5694
|
+
}
|
|
5695
|
+
verdict.durableBeginCommitted = true;
|
|
5696
|
+
}
|
|
5697
|
+
if (rawPaste)
|
|
5698
|
+
verdict.paneWriteStarted = true;
|
|
5699
|
+
// For raw commands the paste itself may alter the live composer. Treat a
|
|
5700
|
+
// failed/throwing write as uncertain rather than retrying bytes into the
|
|
5701
|
+
// same pane; the write-ahead Enter marker later separates safe pre-Enter
|
|
5702
|
+
// recovery from a possibly executed command.
|
|
5378
5703
|
const pasted = await this.tmux.pasteBuffer(formatted);
|
|
5379
5704
|
if (!pasted) {
|
|
5705
|
+
if (rawPaste) {
|
|
5706
|
+
verdict.phase = "raw-paste-write";
|
|
5707
|
+
verdict.proof = "paste-result-unknown";
|
|
5708
|
+
return false;
|
|
5709
|
+
}
|
|
5380
5710
|
const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
|
|
5381
5711
|
const recoverable = this.tmux.isLastPasteFailureRecoverable?.() ?? true;
|
|
5382
5712
|
this.logger[recoverable ? "warn" : "error"]({ attempt, tmuxError, recoverable }, recoverable
|
|
@@ -5392,6 +5722,10 @@ export class Daemon extends EventEmitter {
|
|
|
5392
5722
|
await new Promise(r => setTimeout(r, 2000));
|
|
5393
5723
|
continue;
|
|
5394
5724
|
}
|
|
5725
|
+
// From this point a process/window transition can leave the user payload
|
|
5726
|
+
// in the pane even if later proof is cancelled or unreadable. Such a row
|
|
5727
|
+
// must be uncertain, never aborted back to replayable queued state.
|
|
5728
|
+
verdict.paneWriteStarted = true;
|
|
5395
5729
|
// Settle the bracketed paste, then submit. When control mode can observe
|
|
5396
5730
|
// the pane, wait for the paste's own render to go quiet instead of
|
|
5397
5731
|
// trusting a fixed delay (see waitForPasteSettle). The fixed delays —
|
|
@@ -5414,7 +5748,7 @@ export class Daemon extends EventEmitter {
|
|
|
5414
5748
|
settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
|
|
5415
5749
|
}
|
|
5416
5750
|
let enterAt = Date.now();
|
|
5417
|
-
if (!(await this.sendDeliveryEnter("initial-submit"))) {
|
|
5751
|
+
if (!(await this.sendDeliveryEnter("initial-submit", undefined, durableAttempt))) {
|
|
5418
5752
|
return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
|
|
5419
5753
|
}
|
|
5420
5754
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
@@ -5430,7 +5764,7 @@ export class Daemon extends EventEmitter {
|
|
|
5430
5764
|
// re-baselined to the second Enter so leftover paste-render output between
|
|
5431
5765
|
// the two cannot be what "confirms" the submission.
|
|
5432
5766
|
let enterRetry = false;
|
|
5433
|
-
if (this.backend?.requiresDeliveryEnterRetry?.() === true) {
|
|
5767
|
+
if (!rawPaste && this.backend?.requiresDeliveryEnterRetry?.() === true) {
|
|
5434
5768
|
await new Promise(r => setTimeout(r, 1_000));
|
|
5435
5769
|
const retryAt = Date.now();
|
|
5436
5770
|
if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
|
|
@@ -5475,10 +5809,19 @@ export class Daemon extends EventEmitter {
|
|
|
5475
5809
|
// Re-pasting on a backend without structured Codex pane evidence
|
|
5476
5810
|
// would risk duplication; retain its legacy best-effort behavior.
|
|
5477
5811
|
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
5812
|
+
verdict.phase = "best-effort-submission";
|
|
5813
|
+
verdict.proof = "unverified";
|
|
5478
5814
|
if (status)
|
|
5479
5815
|
this.emit("message_confirmed", status); // ✅ (best-effort)
|
|
5480
5816
|
return true;
|
|
5481
5817
|
}
|
|
5818
|
+
// raw_paste has no unique marker and is not allowed to re-paste or
|
|
5819
|
+
// issue another Enter after its one committed submission attempt.
|
|
5820
|
+
if (rawPaste) {
|
|
5821
|
+
verdict.phase = "raw-paste-native-queue-proof";
|
|
5822
|
+
verdict.proof = proof;
|
|
5823
|
+
return false;
|
|
5824
|
+
}
|
|
5482
5825
|
// Not submitted (or not provably submitted): fall back once to the
|
|
5483
5826
|
// normal idle-gated path, which is the only one with a full
|
|
5484
5827
|
// confirmation ladder. "stranded" is the case that used to be a silent
|
|
@@ -5588,6 +5931,11 @@ export class Daemon extends EventEmitter {
|
|
|
5588
5931
|
}
|
|
5589
5932
|
}
|
|
5590
5933
|
if (!submitted) {
|
|
5934
|
+
if (rawPaste) {
|
|
5935
|
+
verdict.phase = "raw-paste-submit-proof";
|
|
5936
|
+
verdict.proof = "no-positive-acceptance-proof";
|
|
5937
|
+
return false;
|
|
5938
|
+
}
|
|
5591
5939
|
// Legacy system pastes have no unique envelope id. Preserve their
|
|
5592
5940
|
// existing bounded retry until they can be tied to a specific turn.
|
|
5593
5941
|
this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
|
|
@@ -5618,7 +5966,7 @@ export class Daemon extends EventEmitter {
|
|
|
5618
5966
|
// is disqualifying and no amount of output may override it. Where it
|
|
5619
5967
|
// cannot (a backend with no prompt pattern), the output signal remains
|
|
5620
5968
|
// exactly as before — this must not regress backends we cannot read.
|
|
5621
|
-
const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
|
|
5969
|
+
const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry", !rawPaste);
|
|
5622
5970
|
if (becameBusy) {
|
|
5623
5971
|
if (status)
|
|
5624
5972
|
this.emit("message_confirmed", status); // ✅
|
|
@@ -5647,6 +5995,14 @@ export class Daemon extends EventEmitter {
|
|
|
5647
5995
|
}
|
|
5648
5996
|
}
|
|
5649
5997
|
else {
|
|
5998
|
+
// No control client means a raw command has no acceptance proof. The
|
|
5999
|
+
// write-ahead Enter fence keeps it uncertain after this point; never
|
|
6000
|
+
// send a second Enter that could execute a command twice.
|
|
6001
|
+
if (rawPaste) {
|
|
6002
|
+
verdict.phase = "raw-paste-submit-proof";
|
|
6003
|
+
verdict.proof = "unverified";
|
|
6004
|
+
return true;
|
|
6005
|
+
}
|
|
5650
6006
|
// No control client to observe output: fall back to the legacy double-Enter.
|
|
5651
6007
|
await new Promise(r => setTimeout(r, 1000));
|
|
5652
6008
|
await this.sendDeliveryEnter("unobserved-defensive-retry");
|
|
@@ -5702,10 +6058,12 @@ export class Daemon extends EventEmitter {
|
|
|
5702
6058
|
* cannot reappear in just one of them (it did: three copies, two of which
|
|
5703
6059
|
* still confirmed on output alone).
|
|
5704
6060
|
*/
|
|
5705
|
-
async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
|
|
6061
|
+
async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase, allowRecoveryEnter = true) {
|
|
5706
6062
|
if (!this.canProveSubmission()) {
|
|
5707
6063
|
let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
5708
6064
|
if (!busy) {
|
|
6065
|
+
if (!allowRecoveryEnter)
|
|
6066
|
+
return false;
|
|
5709
6067
|
this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
|
|
5710
6068
|
const retryAt = Date.now();
|
|
5711
6069
|
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
@@ -5729,7 +6087,7 @@ export class Daemon extends EventEmitter {
|
|
|
5729
6087
|
break;
|
|
5730
6088
|
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
5731
6089
|
}
|
|
5732
|
-
if (proof !== "stranded")
|
|
6090
|
+
if (proof !== "stranded" || !allowRecoveryEnter)
|
|
5733
6091
|
return false;
|
|
5734
6092
|
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
5735
6093
|
return false;
|
|
@@ -5756,6 +6114,8 @@ export class Daemon extends EventEmitter {
|
|
|
5756
6114
|
// how we got here. Then poll briefly rather than judging on one capture:
|
|
5757
6115
|
// the key takes a moment to be digested, and a single early look would
|
|
5758
6116
|
// report a failure for a message that did go out.
|
|
6117
|
+
if (!allowRecoveryEnter)
|
|
6118
|
+
return false;
|
|
5759
6119
|
this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
|
|
5760
6120
|
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
5761
6121
|
return false;
|
|
@@ -6081,6 +6441,7 @@ export class Daemon extends EventEmitter {
|
|
|
6081
6441
|
const tool = msg.tool;
|
|
6082
6442
|
const args = (msg.args ?? {});
|
|
6083
6443
|
const requestId = msg.requestId;
|
|
6444
|
+
const operationId = typeof msg.operationId === "string" ? msg.operationId : undefined;
|
|
6084
6445
|
this.logger.debug({ tool, requestId }, "Tool call from MCP server");
|
|
6085
6446
|
// A tool call can only come from a live MCP server of ours — proof of life
|
|
6086
6447
|
// that outranks whatever the pid slot says.
|
|
@@ -6103,10 +6464,13 @@ export class Daemon extends EventEmitter {
|
|
|
6103
6464
|
if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
|
|
6104
6465
|
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
6105
6466
|
}
|
|
6106
|
-
const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
|
|
6467
|
+
const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
|
|
6107
6468
|
if (!sent) {
|
|
6108
6469
|
this.logger.warn({ tool, requestId }, "Tool result could not be returned — originating MCP socket is closed");
|
|
6109
6470
|
}
|
|
6471
|
+
else if (operationId) {
|
|
6472
|
+
this.emit("mcp_tool_response_written", { operationId, daemonBootId: this.bootId });
|
|
6473
|
+
}
|
|
6110
6474
|
};
|
|
6111
6475
|
// Repo checkout — handled locally in daemon (no fleet-manager)
|
|
6112
6476
|
if (tool === "checkout_repo") {
|
|
@@ -6231,6 +6595,7 @@ export class Daemon extends EventEmitter {
|
|
|
6231
6595
|
type: "fleet_outbound",
|
|
6232
6596
|
tool,
|
|
6233
6597
|
args,
|
|
6598
|
+
...(operationId ? { operationId, sourceDaemonBootId: this.bootId } : {}),
|
|
6234
6599
|
fleetRequestId: fleetReqId,
|
|
6235
6600
|
senderSessionName,
|
|
6236
6601
|
});
|
|
@@ -6289,6 +6654,7 @@ export class Daemon extends EventEmitter {
|
|
|
6289
6654
|
type: "fleet_outbound",
|
|
6290
6655
|
tool,
|
|
6291
6656
|
args,
|
|
6657
|
+
...(operationId ? { operationId, sourceDaemonBootId: this.bootId } : {}),
|
|
6292
6658
|
fleetRequestId: fleetReqId,
|
|
6293
6659
|
// Preserve the exact adapter world that supplied the chat context. An
|
|
6294
6660
|
// instance binding is only a fallback: persisted/runtime context can be
|
|
@@ -6481,6 +6847,7 @@ export class Daemon extends EventEmitter {
|
|
|
6481
6847
|
agentMode: isCliMode ? "cli" : "mcp",
|
|
6482
6848
|
agentPort: isCliMode ? agentPort : undefined,
|
|
6483
6849
|
backendOptions,
|
|
6850
|
+
peerWorkingDirectories: this.peerWorkingDirectories,
|
|
6484
6851
|
};
|
|
6485
6852
|
}
|
|
6486
6853
|
/**
|
|
@@ -7186,6 +7553,13 @@ export class Daemon extends EventEmitter {
|
|
|
7186
7553
|
}
|
|
7187
7554
|
}
|
|
7188
7555
|
const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
7556
|
+
// e.g. Codex could not read its session DB and chose a fallback (#984):
|
|
7557
|
+
// the operator should know which conversation this launch continues.
|
|
7558
|
+
const launchWarning = this.backend.consumeLaunchWarning?.() ?? null;
|
|
7559
|
+
if (launchWarning) {
|
|
7560
|
+
this.logger.warn({ warning: launchWarning }, "Backend launch fell back");
|
|
7561
|
+
this.emit("backend_launch_warning", { name: this.name, message: launchWarning });
|
|
7562
|
+
}
|
|
7189
7563
|
// Ensure tmux session exists (may have been destroyed if all windows died)
|
|
7190
7564
|
await TmuxManager.ensureSession(this.tmuxSessionName);
|
|
7191
7565
|
if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
|