@cluesmith/codev 3.3.3 → 3.3.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/dashboard-dist/assets/index-BuflRUi6.js +220 -0
- package/dashboard-dist/assets/index-BuflRUi6.js.map +1 -0
- package/dashboard-dist/assets/index-r3bZzLmX.css +32 -0
- package/dashboard-dist/index.html +2 -2
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +48 -0
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/ide.d.ts +20 -0
- package/dist/agent-farm/commands/ide.d.ts.map +1 -0
- package/dist/agent-farm/commands/ide.js +138 -0
- package/dist/agent-farm/commands/ide.js.map +1 -0
- package/dist/agent-farm/commands/inbox.d.ts.map +1 -1
- package/dist/agent-farm/commands/inbox.js +15 -3
- package/dist/agent-farm/commands/inbox.js.map +1 -1
- package/dist/agent-farm/commands/send.d.ts +3 -2
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +40 -6
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn-worktree.js +28 -9
- package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
- package/dist/agent-farm/commands/tower.d.ts +21 -9
- package/dist/agent-farm/commands/tower.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower.js +90 -30
- package/dist/agent-farm/commands/tower.js.map +1 -1
- package/dist/agent-farm/db/tower-owner.d.ts +168 -0
- package/dist/agent-farm/db/tower-owner.d.ts.map +1 -0
- package/dist/agent-farm/db/tower-owner.js +369 -0
- package/dist/agent-farm/db/tower-owner.js.map +1 -0
- package/dist/agent-farm/db/types.d.ts +30 -5
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/lib/ide-record.d.ts +65 -0
- package/dist/agent-farm/lib/ide-record.d.ts.map +1 -0
- package/dist/agent-farm/lib/ide-record.js +118 -0
- package/dist/agent-farm/lib/ide-record.js.map +1 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts +22 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.js +53 -5
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/decisions/file.d.ts +50 -0
- package/dist/agent-farm/servers/decisions/file.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/file.js +146 -0
- package/dist/agent-farm/servers/decisions/file.js.map +1 -0
- package/dist/agent-farm/servers/decisions/grammar.d.ts +36 -0
- package/dist/agent-farm/servers/decisions/grammar.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/grammar.js +109 -0
- package/dist/agent-farm/servers/decisions/grammar.js.map +1 -0
- package/dist/agent-farm/servers/decisions/notice.d.ts +11 -0
- package/dist/agent-farm/servers/decisions/notice.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/notice.js +18 -0
- package/dist/agent-farm/servers/decisions/notice.js.map +1 -0
- package/dist/agent-farm/servers/decisions/parse.d.ts +36 -0
- package/dist/agent-farm/servers/decisions/parse.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/parse.js +308 -0
- package/dist/agent-farm/servers/decisions/parse.js.map +1 -0
- package/dist/agent-farm/servers/decisions/routes.d.ts +17 -0
- package/dist/agent-farm/servers/decisions/routes.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/routes.js +160 -0
- package/dist/agent-farm/servers/decisions/routes.js.map +1 -0
- package/dist/agent-farm/servers/decisions/splice.d.ts +26 -0
- package/dist/agent-farm/servers/decisions/splice.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/splice.js +120 -0
- package/dist/agent-farm/servers/decisions/splice.js.map +1 -0
- package/dist/agent-farm/servers/decisions/stamp.d.ts +4 -0
- package/dist/agent-farm/servers/decisions/stamp.d.ts.map +1 -0
- package/dist/agent-farm/servers/decisions/stamp.js +171 -0
- package/dist/agent-farm/servers/decisions/stamp.js.map +1 -0
- package/dist/agent-farm/servers/gate-profiles.d.ts +23 -2
- package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
- package/dist/agent-farm/servers/gate-profiles.js +43 -2
- package/dist/agent-farm/servers/gate-profiles.js.map +1 -1
- package/dist/agent-farm/servers/ide-forward.d.ts +62 -0
- package/dist/agent-farm/servers/ide-forward.d.ts.map +1 -0
- package/dist/agent-farm/servers/ide-forward.js +274 -0
- package/dist/agent-farm/servers/ide-forward.js.map +1 -0
- package/dist/agent-farm/servers/ide-server.d.ts +68 -0
- package/dist/agent-farm/servers/ide-server.d.ts.map +1 -0
- package/dist/agent-farm/servers/ide-server.js +281 -0
- package/dist/agent-farm/servers/ide-server.js.map +1 -0
- package/dist/agent-farm/servers/mailbox-delivery.d.ts +213 -4
- package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.js +489 -68
- package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.d.ts +18 -4
- package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.js +94 -21
- package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
- package/dist/agent-farm/servers/message-write.d.ts +138 -11
- package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
- package/dist/agent-farm/servers/message-write.js +144 -35
- package/dist/agent-farm/servers/message-write.js.map +1 -1
- package/dist/agent-farm/servers/overview.d.ts +32 -15
- package/dist/agent-farm/servers/overview.d.ts.map +1 -1
- package/dist/agent-farm/servers/overview.js +181 -103
- package/dist/agent-farm/servers/overview.js.map +1 -1
- package/dist/agent-farm/servers/render-gate.d.ts +53 -0
- package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
- package/dist/agent-farm/servers/render-gate.js +135 -24
- package/dist/agent-farm/servers/render-gate.js.map +1 -1
- package/dist/agent-farm/servers/session-submit.d.ts +7 -4
- package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
- package/dist/agent-farm/servers/session-submit.js +7 -4
- package/dist/agent-farm/servers/session-submit.js.map +1 -1
- package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-cron.js +4 -1
- package/dist/agent-farm/servers/tower-cron.js.map +1 -1
- package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-instances.js +6 -7
- package/dist/agent-farm/servers/tower-instances.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +116 -18
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +64 -7
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.js +53 -8
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.js +18 -1
- package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
- package/dist/agent-farm/utils/message-format.d.ts +8 -0
- package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
- package/dist/agent-farm/utils/message-format.js +21 -6
- package/dist/agent-farm/utils/message-format.js.map +1 -1
- package/dist/agent-farm/utils/port.d.ts +22 -0
- package/dist/agent-farm/utils/port.d.ts.map +1 -0
- package/dist/agent-farm/utils/port.js +35 -0
- package/dist/agent-farm/utils/port.js.map +1 -0
- package/dist/agent-farm/utils/server-utils.d.ts +26 -0
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +36 -0
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/consult/index.d.ts +20 -1
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +117 -3
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/consult/tree-tripwire.d.ts +177 -0
- package/dist/commands/consult/tree-tripwire.d.ts.map +1 -0
- package/dist/commands/consult/tree-tripwire.js +356 -0
- package/dist/commands/consult/tree-tripwire.js.map +1 -0
- package/dist/commands/doctor.d.ts +69 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +163 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate-image.d.ts +5 -0
- package/dist/commands/generate-image.d.ts.map +1 -1
- package/dist/commands/generate-image.js +225 -4
- package/dist/commands/generate-image.js.map +1 -1
- package/dist/commands/porch/checks.d.ts +7 -1
- package/dist/commands/porch/checks.d.ts.map +1 -1
- package/dist/commands/porch/checks.js +77 -14
- package/dist/commands/porch/checks.js.map +1 -1
- package/dist/commands/porch/index.d.ts +38 -1
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +79 -43
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/protocol.d.ts +4 -3
- package/dist/commands/porch/protocol.d.ts.map +1 -1
- package/dist/commands/porch/protocol.js +32 -5
- package/dist/commands/porch/protocol.js.map +1 -1
- package/dist/commands/porch/types.d.ts +2 -0
- package/dist/commands/porch/types.d.ts.map +1 -1
- package/dist/lib/agent-env.d.ts +95 -0
- package/dist/lib/agent-env.d.ts.map +1 -0
- package/dist/lib/agent-env.js +137 -0
- package/dist/lib/agent-env.js.map +1 -0
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/forge-contracts.d.ts +10 -0
- package/dist/lib/forge-contracts.d.ts.map +1 -1
- package/dist/lib/forge-contracts.js.map +1 -1
- package/dist/lib/forge-rate-limit.d.ts +45 -0
- package/dist/lib/forge-rate-limit.d.ts.map +1 -0
- package/dist/lib/forge-rate-limit.js +89 -0
- package/dist/lib/forge-rate-limit.js.map +1 -0
- package/dist/lib/forge.d.ts +28 -0
- package/dist/lib/forge.d.ts.map +1 -1
- package/dist/lib/forge.js +63 -10
- package/dist/lib/forge.js.map +1 -1
- package/dist/lib/github.d.ts +13 -1
- package/dist/lib/github.d.ts.map +1 -1
- package/dist/lib/github.js +58 -15
- package/dist/lib/github.js.map +1 -1
- package/dist/lib/test-env.d.ts +17 -0
- package/dist/lib/test-env.d.ts.map +1 -1
- package/dist/lib/test-env.js +20 -0
- package/dist/lib/test-env.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +122 -4
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +142 -7
- package/dist/terminal/pty-session.js.map +1 -1
- package/dist/terminal/session-manager.d.ts +23 -2
- package/dist/terminal/session-manager.d.ts.map +1 -1
- package/dist/terminal/session-manager.js +115 -36
- package/dist/terminal/session-manager.js.map +1 -1
- package/dist/terminal/session-screen.d.ts +34 -5
- package/dist/terminal/session-screen.d.ts.map +1 -1
- package/dist/terminal/session-screen.js +52 -6
- package/dist/terminal/session-screen.js.map +1 -1
- package/dist/terminal/terminal-replies.d.ts +102 -0
- package/dist/terminal/terminal-replies.d.ts.map +1 -0
- package/dist/terminal/terminal-replies.js +181 -0
- package/dist/terminal/terminal-replies.js.map +1 -0
- package/package.json +4 -4
- package/scripts/forge/github/pr-list.sh +6 -2
- package/scripts/forge/github/pr-search.sh +4 -2
- package/scripts/forge/gitlab/issue-search.sh +1 -0
- package/scripts/forge/gitlab/pr-search.sh +35 -2
- package/scripts/forge/linear/auth-status.sh +1 -0
- package/scripts/forge/linear/issue-comment.sh +1 -0
- package/scripts/forge/linear/issue-list.sh +1 -0
- package/scripts/forge/linear/issue-search.sh +1 -0
- package/scripts/forge/linear/issue-view.sh +1 -0
- package/scripts/forge/linear/recently-closed.sh +1 -0
- package/scripts/forge/linear/user-identity.sh +1 -0
- package/skeleton/.claude/skills/afx/SKILL.md +15 -0
- package/skeleton/.claude/skills/arch-init/SKILL.md +56 -9
- package/skeleton/.claude/skills/arch-save/SKILL.md +100 -9
- package/skeleton/.claude/skills/consult/SKILL.md +9 -0
- package/skeleton/.claude/skills/generate-image/SKILL.md +19 -2
- package/skeleton/.claude/skills/porch/SKILL.md +18 -0
- package/skeleton/.codex/skills/afx/SKILL.md +15 -0
- package/skeleton/.codex/skills/arch-init/SKILL.md +56 -9
- package/skeleton/.codex/skills/arch-save/SKILL.md +100 -9
- package/skeleton/.codex/skills/consult/SKILL.md +9 -0
- package/skeleton/.codex/skills/generate-image/SKILL.md +19 -2
- package/skeleton/.codex/skills/porch/SKILL.md +18 -0
- package/skeleton/protocol-schema.json +6 -0
- package/skeleton/protocols/maintain/protocol.md +5 -0
- package/skeleton/protocols/pir/prompts/review.md +0 -1
- package/skeleton/protocols/protocol-schema.json +6 -0
- package/skeleton/resources/commands/agent-farm.md +71 -2
- package/skeleton/resources/commands/consult.md +9 -2
- package/skeleton/roles/consultant.md +32 -0
- package/skeleton/templates/AGENTS.md +1 -1
- package/skeleton/templates/CLAUDE.md +1 -1
- package/skeleton/templates/cheatsheet.md +1 -1
- package/dashboard-dist/assets/index-CUIjS_Vb.js +0 -199
- package/dashboard-dist/assets/index-CUIjS_Vb.js.map +0 -1
- package/dashboard-dist/assets/index-DnYLy5C_.css +0 -32
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { findHeldForAgent, getById, listHeld, markDelivered, setHeldVerdict, pruneTerminal, findEscalatable, markEscalated, markEscalatedDelivered, findStarvingAgents, } from '../db/mailbox.js';
|
|
37
|
+
import { writeStrategyForApp } from './message-write.js';
|
|
37
38
|
import { KeyedSerializer } from './write-queue.js';
|
|
38
39
|
import { formatVerdict, isUnverifiableVerdict } from '@cluesmith/codev-sdk/hold-verdict';
|
|
39
40
|
/**
|
|
@@ -84,6 +85,108 @@ export const SETTLE_BEFORE_WRITE_MS = 250;
|
|
|
84
85
|
function settled(ports, session) {
|
|
85
86
|
return ports.now() - session.lastDataAt >= SETTLE_BEFORE_WRITE_MS;
|
|
86
87
|
}
|
|
88
|
+
const composerSamples = new WeakMap();
|
|
89
|
+
/**
|
|
90
|
+
* The longest gap between two observations that can still be treated as ONE continuous run of
|
|
91
|
+
* the composer holding still (Issue #1664).
|
|
92
|
+
*
|
|
93
|
+
* Two equal fingerprints far apart in time prove nothing on their own: between them the
|
|
94
|
+
* composer could have changed and changed back, which is exactly #1521's turn-end redraw. An
|
|
95
|
+
* agent with no held mail is not sampled at all, so without this bound a sample taken minutes
|
|
96
|
+
* ago would authorise an immediate write onto a composer that had just been repainted —
|
|
97
|
+
* observed in the #1664 acceptance harness, where consecutive trials reused a stale sample and
|
|
98
|
+
* delivered on their very first pass.
|
|
99
|
+
*
|
|
100
|
+
* Set above the drainer's 1.5 s backstop so an ordinary chain of passes always counts as
|
|
101
|
+
* continuous, and the delivery of a held row never depends on a faster trigger existing. In
|
|
102
|
+
* practice the gap is far smaller: a `composer-redraw` hold carries a `retryAfterMs`, so the
|
|
103
|
+
* next observation lands about a settle later.
|
|
104
|
+
*/
|
|
105
|
+
export const MAX_COMPOSER_SAMPLE_GAP_MS = 2_000;
|
|
106
|
+
/**
|
|
107
|
+
* Record the composer region as it looks now, and answer whether it has been UNCHANGED for at
|
|
108
|
+
* least {@link SETTLE_BEFORE_WRITE_MS} (Issue #1664).
|
|
109
|
+
*
|
|
110
|
+
* This is the composer-scoped replacement for {@link settled}. The render gate proves the
|
|
111
|
+
* composer is EMPTY; this proves it has stopped being PAINTED, which is the property #1521
|
|
112
|
+
* actually needed and #1573 approximated with whole-screen output quiescence. The
|
|
113
|
+
* approximation is what this issue exists to remove: measured on a real streaming claude PTY,
|
|
114
|
+
* the screen repainted a median of 8 times a second — so the whole-screen quiet never arrived —
|
|
115
|
+
* while the composer region sat byte-identical for minutes at a time. Mail waited a mean of
|
|
116
|
+
* 54 s, and up to 561 s, for a turn to end, and the recipient was safe to write to the whole
|
|
117
|
+
* time: Claude Code and codex accept input mid-turn and queue it for the next one.
|
|
118
|
+
*
|
|
119
|
+
* A `null` fingerprint (no locatable composer) is never stable — the classifier will have held
|
|
120
|
+
* on the same screen anyway, and an indeterminate region must not start a stability clock.
|
|
121
|
+
*
|
|
122
|
+
* Called on every delivery pass, INCLUDING the ones that go on to hold, so the clock advances
|
|
123
|
+
* across passes rather than restarting each time.
|
|
124
|
+
*/
|
|
125
|
+
function noteComposer(session, fingerprint, now) {
|
|
126
|
+
if (fingerprint === null) {
|
|
127
|
+
composerSamples.delete(session);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const prev = composerSamples.get(session);
|
|
131
|
+
// A different region, or a gap too long to call one continuous observation, restarts the
|
|
132
|
+
// clock: both mean we cannot say what the composer did in between.
|
|
133
|
+
if (!prev || prev.fingerprint !== fingerprint || now - prev.at > MAX_COMPOSER_SAMPLE_GAP_MS) {
|
|
134
|
+
composerSamples.set(session, { fingerprint, since: now, at: now });
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
prev.at = now;
|
|
138
|
+
return now - prev.since >= SETTLE_BEFORE_WRITE_MS;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* How long until {@link noteComposer} would report the current sample stable, in ms — what the
|
|
142
|
+
* drainer arms its one-shot re-drain for (Issue #1664), so a message for a working agent lands
|
|
143
|
+
* about a settle after the composer stops moving rather than on the next backstop tick.
|
|
144
|
+
* Clamped at 0, and {@link SETTLE_BEFORE_WRITE_MS} when there is no sample to measure from.
|
|
145
|
+
*/
|
|
146
|
+
function msUntilComposerSettled(session, now) {
|
|
147
|
+
const prev = composerSamples.get(session);
|
|
148
|
+
if (!prev)
|
|
149
|
+
return SETTLE_BEFORE_WRITE_MS;
|
|
150
|
+
const remaining = SETTLE_BEFORE_WRITE_MS - (now - prev.since);
|
|
151
|
+
return Number.isFinite(remaining) && remaining > 0 ? remaining : 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* How long the receiving session must have been free of HUMAN INPUT before a gated delivery
|
|
155
|
+
* may write onto it (Issue #1473) — the input-side counterpart of {@link SETTLE_BEFORE_WRITE_MS}.
|
|
156
|
+
*
|
|
157
|
+
* The change token catches input that arrives after the gate sampled it. It cannot catch input
|
|
158
|
+
* that arrived just BEFORE the sample and has not been echoed yet: nothing has moved, both
|
|
159
|
+
* samples agree — correctly — and the classifier reads a genuinely empty composer while the
|
|
160
|
+
* character is still travelling to the TUI. Only elapsed time can see that.
|
|
161
|
+
*
|
|
162
|
+
* One notch above the output settle because the round trip it covers is strictly the longer
|
|
163
|
+
* one: an echo has to reach the TUI, be processed, be painted, and come back as output, whereas
|
|
164
|
+
* the output settle only waits for painting to stop.
|
|
165
|
+
*
|
|
166
|
+
* **This BOUNDS the echo-lag window; it does not close it.** Input older than this interval
|
|
167
|
+
* whose echo is still delayed, and input in flight from the browser at sample time, both
|
|
168
|
+
* survive by construction. A larger constant buys coverage at the price of latency on every
|
|
169
|
+
* delivery, which is why the next tightening (if measurement wants one) is a BOUNDED
|
|
170
|
+
* `lastInputAt > lastDataAt` hold rather than a bigger number here.
|
|
171
|
+
*/
|
|
172
|
+
export const INPUT_SETTLE_BEFORE_WRITE_MS = 300;
|
|
173
|
+
/**
|
|
174
|
+
* Has the session been free of human input long enough to write onto (Issue #1473)?
|
|
175
|
+
*
|
|
176
|
+
* Positive `>=` for the same reason {@link settled} is: a session carrying no usable timestamp
|
|
177
|
+
* yields NaN, every NaN comparison is false, and an unknown input age must read as NOT settled.
|
|
178
|
+
*/
|
|
179
|
+
function inputSettled(ports, session) {
|
|
180
|
+
return ports.now() - session.lastInputAt >= INPUT_SETTLE_BEFORE_WRITE_MS;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* How long until {@link inputSettled} would pass, in ms — what the drainer arms its one-shot
|
|
184
|
+
* re-drain for (Issue #1473). Clamped at 0 so a clock skew can never schedule into the past.
|
|
185
|
+
*/
|
|
186
|
+
function msUntilInputSettled(ports, session) {
|
|
187
|
+
const remaining = INPUT_SETTLE_BEFORE_WRITE_MS - (ports.now() - session.lastInputAt);
|
|
188
|
+
return Number.isFinite(remaining) && remaining > 0 ? remaining : 0;
|
|
189
|
+
}
|
|
87
190
|
/**
|
|
88
191
|
* Minimum length of a normalized {@link echoNeedle} for it to be worth matching. Below this a
|
|
89
192
|
* needle is not distinctive enough to prove anything — a two-character raw message would match
|
|
@@ -109,6 +212,17 @@ const MIN_ECHO_NEEDLE_LENGTH = 12;
|
|
|
109
212
|
export function normalizeForEcho(text) {
|
|
110
213
|
return text.replace(/[^A-Za-z0-9]/g, '');
|
|
111
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* The paste placards a TUI renders in place of a long bracketed paste (Issue #1567), in
|
|
217
|
+
* {@link normalizeForEcho} form: claude shows `[Pasted text #N +M lines]`, codex
|
|
218
|
+
* `[Pasted Content N chars]` (both measured, `codev/evidence/1567-head-loss/`). A long frame
|
|
219
|
+
* now travels as a bracketed paste, so until it is submitted the composer shows one of these
|
|
220
|
+
* instead of the header — and a `--no-enter` send never submits. The echo watch therefore
|
|
221
|
+
* counts these alongside the header needle; a NEW occurrence of either is evidence the write
|
|
222
|
+
* landed. Counted, not merely matched, for the same reason as the header: a previous paste's
|
|
223
|
+
* placard may already be on screen.
|
|
224
|
+
*/
|
|
225
|
+
export const PASTE_PLACARD_NEEDLES = ['Pastedtext', 'PastedContent'];
|
|
112
226
|
/**
|
|
113
227
|
* The needle {@link DeliveryPorts.watchEcho} opens on: the formatted message's FIRST line,
|
|
114
228
|
* normalized. Returns `''` when it is too short to be distinctive (see
|
|
@@ -141,16 +255,31 @@ export function agentKey(workspacePath, toAgent) {
|
|
|
141
255
|
* (Spec 1313 render-gate round 2 — the fix for the non-monotone `currentSeq:partialBytes`
|
|
142
256
|
* pair, which aliased once #1205's partial trim made `partialBytes` decrease), and the
|
|
143
257
|
* geometry catches a resize that reflows the screen without new output. So two samples that
|
|
144
|
-
* match mean the classified screen is byte-for-byte unchanged.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
258
|
+
* match mean the classified screen is byte-for-byte unchanged.
|
|
259
|
+
*
|
|
260
|
+
* ONE consumer relies on that now — the drainer's verdict memo ({@link CachedVerdict}): a cached
|
|
261
|
+
* verdict is reused only while this token is unchanged, so a static screen is classified once
|
|
262
|
+
* instead of re-checked every 1.5 s backstop tick. Issue #1664 retired the second consumer, the
|
|
263
|
+
* gate→write TOCTOU re-validation, because its OUTPUT half held on every repaint of a working
|
|
264
|
+
* agent — the defect that issue is named for. That re-validation now compares the composer-region
|
|
265
|
+
* fingerprint (output side) and `inputSeq` directly (input side); see `noteComposer` and the
|
|
266
|
+
* in-lock precheck.
|
|
267
|
+
*
|
|
268
|
+
* `inputSeq` stays in this token because the MEMO earns it on its own (Issue #1473). A
|
|
269
|
+
* `CachedVerdict` survives across backstop ticks, so the gap between the cached classify and its
|
|
270
|
+
* reuse is bounded by no settle at all. Without an input term a CLEAN verdict would be reusable
|
|
271
|
+
* ACROSS a keystroke — PTY input never advances the ring, so the output token is genuinely
|
|
272
|
+
* unchanged.
|
|
273
|
+
*
|
|
274
|
+
* (The delivery's OWN paced write is excluded by construction — it writes with the `'delivery'`
|
|
275
|
+
* origin, which moves no input signal — so folding this in cannot make a delivery block itself.)
|
|
276
|
+
*
|
|
277
|
+
* Monotonicity, precisely: the TOKEN is not globally monotone (geometry and app can change back
|
|
278
|
+
* and forth), but both COUNTERS in it are, and that plus {@link CachedVerdict}'s session-identity
|
|
279
|
+
* guard is what makes non-aliasing hold.
|
|
151
280
|
*/
|
|
152
281
|
function ringToken(session, profile) {
|
|
153
|
-
return `${session.bytesWritten}:${session.info.cols}x${session.info.rows}:${profile.app}`;
|
|
282
|
+
return `${session.bytesWritten}:${session.inputSeq}:${session.info.cols}x${session.info.rows}:${profile.app}`;
|
|
154
283
|
}
|
|
155
284
|
/** Reconstruct the delivered-message broadcast frame from a persisted row. */
|
|
156
285
|
export function broadcastForRow(row, now) {
|
|
@@ -186,17 +315,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
186
315
|
const held = findHeldForAgent(db, workspacePath, toAgent, ports.now());
|
|
187
316
|
if (held.length === 0)
|
|
188
317
|
return { delivered: [], reason: null };
|
|
189
|
-
// Every NON-gate hold, plus the post-classify re-holds (token moved / not settled).
|
|
190
|
-
//
|
|
191
|
-
//
|
|
318
|
+
// Every NON-gate hold, plus the post-classify re-holds (token moved / not settled). `detail`
|
|
319
|
+
// defaults to null (Issue #1482): a detail describes a gate verdict, and holding for
|
|
320
|
+
// `no-live-pty` — or re-holding a screen that moved under us — is not one. Leaving the
|
|
192
321
|
// previous detail in place would let `afx inbox` keep asserting "a human is at the composer"
|
|
193
322
|
// about a session whose PTY has since died.
|
|
194
|
-
|
|
323
|
+
//
|
|
324
|
+
// The one caller that passes a detail is the input hold (Issue #1473). It needs one because
|
|
325
|
+
// `hold('busy')` with a null detail is invisible to #1482's whole diagnostic axis AND to
|
|
326
|
+
// `isClassifierStuck`, which excludes plain `busy` — so an input hold that RECURRED (an app
|
|
327
|
+
// polling geometry every repaint, say, whose reply this module failed to recognise) would
|
|
328
|
+
// hold indefinitely with nothing to escalate it and nothing on any surface saying why. With
|
|
329
|
+
// the detail, `afx inbox` and the send response read `busy:recent-input` through the existing
|
|
330
|
+
// shared formatter, no formatter change required.
|
|
331
|
+
const hold = (reason, detail = null) => {
|
|
195
332
|
for (const row of held) {
|
|
196
|
-
if (row.reason !== reason || row.detail !==
|
|
197
|
-
setHeldVerdict(db, row.id, reason,
|
|
333
|
+
if (row.reason !== reason || row.detail !== detail)
|
|
334
|
+
setHeldVerdict(db, row.id, reason, detail, ports.now());
|
|
198
335
|
}
|
|
199
|
-
return { delivered: [], reason };
|
|
336
|
+
return { delivered: [], reason, ...(detail ? { detail } : {}) };
|
|
200
337
|
};
|
|
201
338
|
const session = ports.getSessionForAgent(workspacePath, toAgent);
|
|
202
339
|
if (!session)
|
|
@@ -204,17 +341,26 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
204
341
|
const profile = ports.resolveProfile(session);
|
|
205
342
|
if (!profile)
|
|
206
343
|
return hold('no-profile');
|
|
207
|
-
|
|
208
|
-
|
|
344
|
+
/** {@link hold} for the input-settle case, carrying the drainer's one-shot re-drain delay. */
|
|
345
|
+
const holdOnInput = () => ({
|
|
346
|
+
...hold('busy', 'recent-input'),
|
|
347
|
+
retryAfterMs: msUntilInputSettled(ports, session),
|
|
348
|
+
});
|
|
349
|
+
// Sample the ring's change-token BEFORE the (possibly memoized) classify — the memo's reuse
|
|
350
|
+
// key (Issue #1664 retired its second job, the post-classify screen re-validation; the
|
|
351
|
+
// composer fingerprint below does that now, scoped to the rows that matter).
|
|
209
352
|
const tokenBefore = ringToken(session, profile);
|
|
353
|
+
// …and the input counter on its own: it is what catches a human keystroke landing in the
|
|
354
|
+
// gate→write window, and it is the only signal that can, since PTY input advances nothing on
|
|
355
|
+
// the screen the classifier read (Issue #1473).
|
|
356
|
+
const inputSeqBefore = session.inputSeq;
|
|
210
357
|
// Verdict memo (Spec 1313 render-gate follow-up). The 1.5 s backstop re-checks every held
|
|
211
358
|
// agent's screen each tick; for a STATIC screen that classify is pure waste. Reuse the
|
|
212
359
|
// cached verdict while BOTH the live session instance AND the token are unchanged — the token
|
|
213
360
|
// advances on ANY new output, so a match means the screen is byte-for-byte what we already
|
|
214
361
|
// classified, and the session guard closes the PTY-respawn aliasing route (a replacement
|
|
215
362
|
// session is a DIFFERENT object); the monotone token closes the old `RingBuffer.clear()` route
|
|
216
|
-
// (see {@link CachedVerdict}). A memo hit does NO await, so
|
|
217
|
-
// below (`ringToken(...) !== tokenBefore`) passes trivially: no keystroke can land in a
|
|
363
|
+
// (see {@link CachedVerdict}). A memo hit does NO await, so no keystroke can land in a
|
|
218
364
|
// classify window that never opened. The memo is owned + bounded by the drainer's backstop
|
|
219
365
|
// {@link MailboxDrainer.tick} (pruned to the held-agent set each tick); every OTHER caller —
|
|
220
366
|
// the request/cron paths and the fast scheduleDrain trigger — passes none and classifies
|
|
@@ -237,6 +383,13 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
237
383
|
// branch); narrowing it away keeps the persisted set to the three not-clean values the
|
|
238
384
|
// column's type admits.
|
|
239
385
|
const detail = verdict.detail === 'empty' ? null : verdict.detail;
|
|
386
|
+
// Discard the composer STABILITY sample (Issue #1664, CMAP round 1 — claude). A not-clean
|
|
387
|
+
// verdict is positive EVIDENCE that the composer moved or cannot be read: a draft appeared,
|
|
388
|
+
// a dialog opened, a frame arrived mid-repaint. Keeping the previous sample would let a
|
|
389
|
+
// later clean pass with a coincidentally identical fingerprint claim the region "held still"
|
|
390
|
+
// across the very interval we watched it move — writing onto a composer we know changed.
|
|
391
|
+
// Evidence against stability must reset the clock, not be skipped over.
|
|
392
|
+
composerSamples.delete(session);
|
|
240
393
|
for (const row of held) {
|
|
241
394
|
if (row.reason !== reason || row.detail !== detail) {
|
|
242
395
|
setHeldVerdict(db, row.id, reason, detail, ports.now());
|
|
@@ -244,21 +397,54 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
244
397
|
}
|
|
245
398
|
return { delivered: [], reason, detail: verdict.detail };
|
|
246
399
|
}
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
400
|
+
// Settle-before-write (Issue #1573, rescoped by Issue #1664). A clean verdict says the
|
|
401
|
+
// composer is EMPTY, not that it has finished being DRAWN, and writing into a composer
|
|
402
|
+
// mid-repaint is what ate the leading bytes in #1521. The proof used to be whole-screen
|
|
403
|
+
// output quiescence — which a working agent never offers, because it is repainting a spinner
|
|
404
|
+
// and a streaming transcript several times a second — so mail queued behind entire turns for
|
|
405
|
+
// a hazard that lives only in the composer. Ask the composer instead: deliver when its region
|
|
406
|
+
// has been byte-identical across two samples a settle apart, OR when the whole screen is
|
|
407
|
+
// quiet (the original proof, kept because it is strictly stronger and lets an IDLE recipient
|
|
408
|
+
// deliver on the very first pass, with no second sample to wait for).
|
|
409
|
+
//
|
|
410
|
+
// Sampled here rather than inside `classify` so it is the FRESHEST reading of the composer:
|
|
411
|
+
// the classify may have awaited (the mirror flushes its parser, and xterm yields between
|
|
412
|
+
// parse slices), and this fingerprint is taken after that await returns. So it doubles as the
|
|
413
|
+
// output half of the gate→write TOCTOU: a draft that appeared during the classify moves the
|
|
414
|
+
// region, which cannot then be stable, and we hold rather than fusing the message into it.
|
|
415
|
+
const fingerprint = ports.composerFingerprint(session, profile);
|
|
416
|
+
// One clock reading for the whole decision: `noteComposer` records against it and
|
|
417
|
+
// `msUntilComposerSettled` measures from it, so the recorded sample and the retry delay can
|
|
418
|
+
// never describe two different instants.
|
|
419
|
+
const sampledAt = ports.now();
|
|
420
|
+
const composerStable = noteComposer(session, fingerprint, sampledAt);
|
|
421
|
+
// …and composer stability is only a licence to write for an app MEASURED to accept input
|
|
422
|
+
// mid-turn and queue it (`GateProfile.queuesInputMidTurn`). For any other app the original
|
|
423
|
+
// whole-screen settle stands: an app that DROPS input arriving mid-turn must only ever be
|
|
424
|
+
// written to while it is quiet, and the cost of being wrong there is a silently lost message,
|
|
425
|
+
// not a slow one. The capability is per-app measurement, never inheritance.
|
|
426
|
+
const mayWriteWhileTalking = profile.queuesInputMidTurn === true && composerStable;
|
|
427
|
+
if (fingerprint === null || (!mayWriteWhileTalking && !settled(ports, session))) {
|
|
428
|
+
return {
|
|
429
|
+
...hold('busy', 'composer-redraw'),
|
|
430
|
+
retryAfterMs: msUntilComposerSettled(session, sampledAt),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// Re-validate the INPUT half of the change token (Issue #1473). A human keystroke landing
|
|
434
|
+
// during the classify moves nothing on the screen the classifier read — PTY input does not
|
|
435
|
+
// advance the ring, and the character may not be echoed yet — so only this counter sees it.
|
|
436
|
+
// The output half is gone (Issue #1664): it re-held on every repaint above, which is the
|
|
437
|
+
// whole defect, and the composer fingerprint sampled above is a strictly better answer to
|
|
438
|
+
// the question it was asking.
|
|
439
|
+
if (session.inputSeq !== inputSeqBefore)
|
|
440
|
+
return hold('busy', 'recent-input');
|
|
441
|
+
// Input-settle (Issue #1473). The token above sees input that arrived AFTER we sampled it;
|
|
442
|
+
// this sees input that arrived just BEFORE and has not been echoed yet — nothing moved, both
|
|
443
|
+
// samples agree, and the classifier read a genuinely empty composer while the character was
|
|
444
|
+
// still in flight. Only elapsed time can catch that. Re-checked in the lock below for the
|
|
445
|
+
// same reason the output settle is.
|
|
446
|
+
if (!inputSettled(ports, session))
|
|
447
|
+
return holdOnInput();
|
|
262
448
|
// Clean, verified-empty prompt → deliver the oldest held message. Await the
|
|
263
449
|
// write's paced completion so a serialized follow-up delivery never begins
|
|
264
450
|
// until this message's text + Enter is fully on the wire.
|
|
@@ -292,18 +478,81 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
292
478
|
// `--interrupt`/`--escape` had just cleared. Cheap enough to repeat — a synchronous ring
|
|
293
479
|
// read and one indexed better-sqlite3 lookup.
|
|
294
480
|
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// human's keystrokes over the WebSocket
|
|
298
|
-
//
|
|
299
|
-
//
|
|
481
|
+
// Issue #1473 narrowed the residual this block used to describe. `inputSeq` moves for input
|
|
482
|
+
// from a writer that does NOT take this lock — the raw `/api/terminals/:id/write` passthrough,
|
|
483
|
+
// a human's keystrokes over the WebSocket, the delayed `^C` — even while it sits un-echoed on
|
|
484
|
+
// the line, and the `inputSettled` check below covers input that landed before we sampled.
|
|
485
|
+
// Issue #1664 replaced the OUTPUT half of that re-check (a whole-screen `bytesWritten`
|
|
486
|
+
// comparison plus the settle) with the composer-region fingerprint, so a spinner frame landing
|
|
487
|
+
// during the lock wait no longer aborts a delivery the recipient was never unsafe to receive.
|
|
488
|
+
// What SURVIVES, and must not be claimed closed:
|
|
489
|
+
//
|
|
490
|
+
// • input older than the input-settle interval whose echo is still delayed, and input in
|
|
491
|
+
// flight from the browser at sample time — the settle bounds this window, it does not
|
|
492
|
+
// close it;
|
|
493
|
+
// • an `afx attach` client entirely: it speaks to the shellper socket directly and never
|
|
494
|
+
// touches `PtySession`, so neither its input nor its terminal's replies are observed here;
|
|
495
|
+
// • a reply shape `stripTerminalReplies` does not recognise, which counts as input — a
|
|
496
|
+
// spurious hold, now visible as `busy:recent-input` rather than silent;
|
|
497
|
+
// • a race DURING the paced write, which is reported (`racedByInput`) rather than prevented,
|
|
498
|
+
// because by then the bytes are already out;
|
|
499
|
+
// • (Issue #1664) a composer that redraws and returns to a byte-identical rendering entirely
|
|
500
|
+
// inside this lock wait — the whole-screen settle this replaced did cover it, at the price
|
|
501
|
+
// of never delivering to a working agent at all; `MAX_COMPOSER_SAMPLE_GAP_MS` bounds it,
|
|
502
|
+
// and `SessionScreen.peek()` refuses to answer at all while any fed byte is unparsed, so
|
|
503
|
+
// a redraw cannot hide in the parse queue;
|
|
504
|
+
// • (Issue #1664, NEW with this change) a turn ending DURING the paced write. The write
|
|
505
|
+
// spans ~100 ms across setTimeout gaps and is fingerprint-checked once, at its first byte.
|
|
506
|
+
// Under the old settle this could not arise — a delivery only ever wrote to an agent that
|
|
507
|
+
// had been silent for 250 ms — whereas a delivery to a WORKING agent can now be overtaken
|
|
508
|
+
// by that agent's own turn-end repaint.
|
|
509
|
+
//
|
|
510
|
+
// NOT fully detected, and the detection it does have must not be overstated: `watchEcho`
|
|
511
|
+
// matches the message's HEADER (or a paste placard) and nothing else, so it catches a
|
|
512
|
+
// redraw that eats the LEADING bytes (#1521's shape → `verified: false`) and is blind to
|
|
513
|
+
// one that truncates the TAIL — the header rendered, so the delivery reports confirmed
|
|
514
|
+
// while the body is short. That gap is not new to this issue (the watch has always been
|
|
515
|
+
// header-only) but this issue widens the window in which it can be hit, so it is worth
|
|
516
|
+
// naming rather than leaving inside a general "detected, not prevented". Tracked as a
|
|
517
|
+
// follow-up under #1578; a tail oracle would need the write edge to know what it sent and
|
|
518
|
+
// the watch to look for the END of it.
|
|
519
|
+
//
|
|
520
|
+
// Measured across 100 delivering trials against a real claude — the #1664 streaming,
|
|
521
|
+
// turn-end and re-run acceptance runs — head-loss was 0/100. Those trials DO check the
|
|
522
|
+
// tail: each body carries a unique trailing token and the harness asserts it rendered, so
|
|
523
|
+
// the empirical bound covers the case the production watch cannot see.
|
|
300
524
|
const precheck = () => {
|
|
301
525
|
if (!session.writable)
|
|
302
526
|
return { kind: 'hold', reason: 'no-live-pty' };
|
|
303
|
-
if (
|
|
304
|
-
return { kind: 'hold', reason: 'busy' };
|
|
305
|
-
|
|
527
|
+
if (session.inputSeq !== inputSeqBefore) {
|
|
528
|
+
return { kind: 'hold', reason: 'busy', detail: 'recent-input' };
|
|
529
|
+
}
|
|
530
|
+
// For an app NOT measured to queue input mid-turn, the whole-screen settle is still the
|
|
531
|
+
// rule, and it is re-checked here exactly as it was before Issue #1664 — that app's delivery
|
|
532
|
+
// path is unchanged end to end.
|
|
533
|
+
if (profile.queuesInputMidTurn !== true && !settled(ports, session)) {
|
|
306
534
|
return { kind: 'hold', reason: 'busy' };
|
|
535
|
+
}
|
|
536
|
+
// Issue #1664: the composer region, not the whole screen. An `--interrupt`/`--escape` that
|
|
537
|
+
// cleared the line during our lock wait, or a turn-end box redraw, moves this; the spinner
|
|
538
|
+
// and the streaming transcript above it do not. A `null` fingerprint (no locatable
|
|
539
|
+
// composer) can never equal the sample, so it holds — the fail-safe direction.
|
|
540
|
+
if (ports.composerFingerprint(session, profile) !== fingerprint) {
|
|
541
|
+
return {
|
|
542
|
+
kind: 'hold',
|
|
543
|
+
reason: 'busy',
|
|
544
|
+
detail: 'composer-redraw',
|
|
545
|
+
retryAfterMs: SETTLE_BEFORE_WRITE_MS,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (!inputSettled(ports, session)) {
|
|
549
|
+
return {
|
|
550
|
+
kind: 'hold',
|
|
551
|
+
reason: 'busy',
|
|
552
|
+
detail: 'recent-input',
|
|
553
|
+
retryAfterMs: msUntilInputSettled(ports, session),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
307
556
|
const stillHeld = getById(db, row.id);
|
|
308
557
|
if (!stillHeld || stillHeld.status !== 'held')
|
|
309
558
|
return { kind: 'row-resolved' };
|
|
@@ -319,7 +568,7 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
319
568
|
// delivery); the try either assigns the real result or throws past this point.
|
|
320
569
|
let result = { status: 'aborted', abort: { kind: 'hold', reason: 'busy' } };
|
|
321
570
|
try {
|
|
322
|
-
result = await ports.writeMessage(session, current.formatted_message, current.no_enter === 1, precheck);
|
|
571
|
+
result = await ports.writeMessage(session, current.formatted_message, current.no_enter === 1, precheck, writeStrategyForApp(profile.app));
|
|
323
572
|
}
|
|
324
573
|
finally {
|
|
325
574
|
// Invalidate the memo on EVERY write outcome — a clean `true`, a dropped-write `false`, OR a
|
|
@@ -333,11 +582,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
333
582
|
// putting SOME bytes on the wire, e.g. the text landed but the Enter dropped → we hold below;
|
|
334
583
|
// (c) writeMessage REJECTS after partial bytes — its port contract (`boolean | Promise<boolean>`)
|
|
335
584
|
// permits a binding to throw, and a bare throw would skip a delete placed after the await. In
|
|
336
|
-
// every case a leftover CLEAN would let a follow-up held message memo-hit the SAME token
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// the
|
|
585
|
+
// every case a leftover CLEAN would let a follow-up held message memo-hit the SAME token and
|
|
586
|
+
// write onto the not-yet-echoed line, so the memo must die here. (A DELIVERY's own bytes still
|
|
587
|
+
// advance no signal by design — it writes with the `'delivery'` origin — so this delete, not
|
|
588
|
+
// the token, is what covers our own write.) Issue #1473 closed the sibling case, a HUMAN
|
|
589
|
+
// keystroke: `ringToken` now carries the session's input counter, so a memoized CLEAN verdict
|
|
590
|
+
// can no longer be reused across one. What survives is the echo-lag window the input-settle
|
|
591
|
+
// interval BOUNDS rather than closes — input older than the settle whose echo is still
|
|
592
|
+
// delayed, and input in flight from the client at sample time.
|
|
340
593
|
memo?.delete(cacheKey);
|
|
594
|
+
// …and the composer STABILITY sample, for the same reason and in the same breath (Issue
|
|
595
|
+
// #1664, CMAP round 1 — codex). This delivery's own bytes are a composer change we KNOW
|
|
596
|
+
// happened: the body lands on the line, the Enter submits it, and the TUI paints a fresh
|
|
597
|
+
// prompt. Left in place, that pre-write observation is proof of stability for a composer
|
|
598
|
+
// that has since been rewritten twice — so a second delivery arriving inside
|
|
599
|
+
// `MAX_COMPOSER_SAMPLE_GAP_MS`, finding the same empty-composer fingerprint, would be
|
|
600
|
+
// authorised to write IMMEDIATELY into the redraw following our own submit. That is #1521's
|
|
601
|
+
// window, reopened by our own hand. The gap bound cannot help here (the reuse is well
|
|
602
|
+
// inside it), and unlike a third-party write this one needs no inference to detect.
|
|
603
|
+
composerSamples.delete(session);
|
|
341
604
|
}
|
|
342
605
|
// Anything short of a complete submit holds the row — a delivery is marked delivered only when
|
|
343
606
|
// every byte, Enter included, reached the terminal.
|
|
@@ -377,7 +640,13 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
377
640
|
ports.onHeldStateChange(); // the held set changed under us → refresh the indicator
|
|
378
641
|
return { delivered: [], reason: null };
|
|
379
642
|
}
|
|
380
|
-
|
|
643
|
+
// Carry the in-lock refusal's detail and re-drain delay through (Issue #1473): an input
|
|
644
|
+
// hold decided inside the lock is the same hold as one decided before it, and must reach
|
|
645
|
+
// the operator surfaces and the drainer's retry timer identically.
|
|
646
|
+
const outcome = hold(result.abort.reason, result.abort.detail ?? null);
|
|
647
|
+
return result.abort.retryAfterMs === undefined
|
|
648
|
+
? outcome
|
|
649
|
+
: { ...outcome, retryAfterMs: result.abort.retryAfterMs };
|
|
381
650
|
}
|
|
382
651
|
// THE POINT OF NO RETURN (Issue #1584). Past this line the write COMPLETED — every byte,
|
|
383
652
|
// Enter included, was accepted by the session — so the row is at-least-once delivered and
|
|
@@ -432,26 +701,49 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
432
701
|
verified = false;
|
|
433
702
|
ports.log(`[mailbox] echo verification errored for ${row.id.slice(0, 8)}…: ${String(err)}`, 'WARN');
|
|
434
703
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
704
|
+
}
|
|
705
|
+
// The escalation decision sits OUTSIDE `if (echo)` (Issue #1473). A during-write input race
|
|
706
|
+
// is a reason to flag a delivery whether or not it had a needle worth matching — and in its
|
|
707
|
+
// most common shape (a human's Enter submitting our half-written body) the header DID land,
|
|
708
|
+
// so `verified` is `true` and the old `if (!verified)` inside the echo block never fired.
|
|
709
|
+
// Computed once, so a delivery that is both raced AND unechoed escalates exactly once.
|
|
710
|
+
const racedByInput = result.racedByInput === true;
|
|
711
|
+
const unverified = racedByInput || verified === false;
|
|
712
|
+
if (unverified) {
|
|
713
|
+
// `'input-raced'` takes precedence: it names the more actionable remedy, and a missing echo
|
|
714
|
+
// is frequently just the downstream symptom of the race.
|
|
715
|
+
const cause = racedByInput ? 'input-raced' : 'no-echo';
|
|
716
|
+
// The row is already `delivered`, so this is the delivered-only counterpart of the
|
|
717
|
+
// drainer's held-only `markEscalated`.
|
|
718
|
+
markEscalatedDelivered(db, row.id, ports.now());
|
|
719
|
+
// BRANCH the text rather than appending to it. The no-echo wording asserts the header never
|
|
720
|
+
// appeared, which is false for the raced case; and its `needle N chars` clause would run
|
|
721
|
+
// with no needle at all here and print "needle 0 chars".
|
|
722
|
+
const what = racedByInput
|
|
723
|
+
? `the write completed, but the terminal received input while it was in flight, so the ` +
|
|
724
|
+
`body may have been truncated or submitted early`
|
|
725
|
+
: `the write completed but neither its header nor a paste placard appeared on the ` +
|
|
726
|
+
`terminal (needle ${needle ? needle.length : 0} chars)`;
|
|
727
|
+
ports.log(`[mailbox] delivered-unverified ${row.id.slice(0, 8)}… → ${toAgent} @ ` +
|
|
728
|
+
`${path.basename(workspacePath)} (terminal ${session.id}, ${cause}): ${what}. Recorded as ` +
|
|
729
|
+
`delivered and flagged — NOT re-written (Issue #1584).`, 'WARN');
|
|
730
|
+
// Raise it where a human will see it. The sender's response covers an interactive
|
|
731
|
+
// `afx send`, but a cron or backstop delivery has no sender waiting on a response, and a
|
|
732
|
+
// DELIVERED row is invisible to every held-scoped surface (`afx inbox`, the held-count
|
|
733
|
+
// indicator) — without this its only trace is a log line (CMAP round 1 — Codex).
|
|
734
|
+
ports.onUnverifiedDelivery?.({
|
|
735
|
+
workspacePath,
|
|
736
|
+
toAgent,
|
|
737
|
+
mailboxId: row.id,
|
|
738
|
+
terminalId: session.id,
|
|
739
|
+
cause,
|
|
740
|
+
});
|
|
741
|
+
return {
|
|
742
|
+
delivered: [row.id],
|
|
743
|
+
reason: null,
|
|
744
|
+
unverifiedCause: cause,
|
|
745
|
+
...(verified === undefined ? {} : { verified }),
|
|
746
|
+
};
|
|
455
747
|
}
|
|
456
748
|
return { delivered: [row.id], reason: null, ...(verified === undefined ? {} : { verified }) };
|
|
457
749
|
}
|
|
@@ -496,6 +788,26 @@ const LIVENESS_STREAK_THRESHOLD = 10;
|
|
|
496
788
|
// spam owners. Derived from escalationMs (default 60s → 180s), so it scales with the
|
|
497
789
|
// configured `mailbox.escalationSeconds` without a separate config knob.
|
|
498
790
|
const DEFAULT_OWNER_NOTICE_MULTIPLE = 3;
|
|
791
|
+
// Issue #1473: slack added to a pass's `retryAfterMs` when arming the one-shot input re-drain,
|
|
792
|
+
// so the retry lands just PAST the settle boundary rather than exactly on it (where a timer
|
|
793
|
+
// firing a millisecond early would re-hold and cost another full cycle).
|
|
794
|
+
const INPUT_RETRY_MARGIN_MS = 25;
|
|
795
|
+
// Issue #1473: how long an agent must hold on recent input, UNBROKEN, before the drainer logs a
|
|
796
|
+
// diagnostic. A human types in BURSTS, so a run this long without a single gap is a machine, not
|
|
797
|
+
// a person — most likely a reply shape the filter does not recognise, arriving on every repaint.
|
|
798
|
+
// Deliberately a log line and not an escalation: the hold class it describes is the same "a human
|
|
799
|
+
// is at the line" class as `user-text`, and wiring it to the escalation path would false-alarm on
|
|
800
|
+
// every ordinary typist. This is the trace that makes the otherwise-silent case findable later.
|
|
801
|
+
//
|
|
802
|
+
// ELAPSED TIME, not a pass count. It was a count of 60, documented as "~90s at the 300ms re-drain
|
|
803
|
+
// cadence" — but the arithmetic was wrong (60 x ~325ms is ~20s) and, worse, a count silently
|
|
804
|
+
// re-scales whenever the drain cadence changes: the backstop, the quiescence trigger and the
|
|
805
|
+
// submit trigger all drive passes too, so the real interval is bounded above by the re-drain and
|
|
806
|
+
// has no fixed floor. Twenty seconds of unbroken cursor-key or mouse activity is something a
|
|
807
|
+
// person genuinely does — the manual verification for this very issue ran 15-20s of continuous
|
|
808
|
+
// arrow presses per repetition — so the old threshold would have libelled a human as a machine.
|
|
809
|
+
// A wall-clock bound says what the comment always meant and cannot drift with the cadence.
|
|
810
|
+
const CONSECUTIVE_INPUT_HOLD_WARN_MS = 90_000;
|
|
499
811
|
/**
|
|
500
812
|
* The poll backstop that replaces `SendBuffer`'s flush timer. On each tick it walks
|
|
501
813
|
* every agent with held mail and runs {@link deliverAgentMail}, so a message held
|
|
@@ -532,6 +844,14 @@ export class MailboxDrainer {
|
|
|
532
844
|
// with the persistent bounded mirror, a classify is O(viewport) regardless of history, so there
|
|
533
845
|
// is no expensive whole-ring render left to throttle — every tick just re-classifies cheaply.)
|
|
534
846
|
verdictMemo = new Map();
|
|
847
|
+
// Issue #1473: the one-shot input re-drain. At most one pending timer per agent (a later
|
|
848
|
+
// hold coalesces onto the timer already armed), generation-guarded exactly like
|
|
849
|
+
// `scheduleDrain`, and cleared in `stop()` alongside the backstop timer.
|
|
850
|
+
inputRetryTimers = new Map();
|
|
851
|
+
// Issue #1473: each agent's unbroken run of input-holds — the diagnostic behind
|
|
852
|
+
// {@link CONSECUTIVE_INPUT_HOLD_WARN_MS}. Reset by any outcome that is not an input hold,
|
|
853
|
+
// delivery included; that reset is what makes the run UNBROKEN rather than cumulative.
|
|
854
|
+
consecutiveInputHolds = new Map();
|
|
535
855
|
// Lifecycle generation (CMAP round 2 — Codex/Claude): the drainer instance is REUSED across
|
|
536
856
|
// stop()/start() (mailbox-wiring `ensureDrainer`), and the tests do start/stop/start. Bumped on
|
|
537
857
|
// stop() so an in-flight tick/scheduleDrain that resumes after a restart bails before mutating
|
|
@@ -577,12 +897,111 @@ export class MailboxDrainer {
|
|
|
577
897
|
this.notCleanStreak.clear();
|
|
578
898
|
this.scheduledDrains.clear();
|
|
579
899
|
this.notifiedAgents.clear();
|
|
900
|
+
// Issue #1473: the input re-drain timers are real pending timers, not just map entries, so
|
|
901
|
+
// they must be CLEARED here and not merely dropped — the generation guard inside each one
|
|
902
|
+
// would make a survivor harmless, but leaving it pending keeps the event loop alive.
|
|
903
|
+
for (const timer of this.inputRetryTimers.values())
|
|
904
|
+
clearTimeout(timer);
|
|
905
|
+
this.inputRetryTimers.clear();
|
|
906
|
+
this.consecutiveInputHolds.clear();
|
|
580
907
|
this.generation++;
|
|
581
908
|
}
|
|
582
909
|
/** Per-agent consecutive not-clean count (liveness telemetry; Phase 7 reads this). */
|
|
583
910
|
get streaks() {
|
|
584
911
|
return this.notCleanStreak;
|
|
585
912
|
}
|
|
913
|
+
/** Agents with a pending input re-drain timer (Issue #1473; test/observability). */
|
|
914
|
+
get pendingInputRetries() {
|
|
915
|
+
return [...this.inputRetryTimers.keys()];
|
|
916
|
+
}
|
|
917
|
+
/** Per-agent consecutive input-hold count (Issue #1473; test/observability). */
|
|
918
|
+
get inputHoldStreaks() {
|
|
919
|
+
return new Map([...this.consecutiveInputHolds].map(([k, v]) => [k, v.count]));
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Arm the input re-drain for a pass this drainer did not itself run (Issue #1473).
|
|
923
|
+
*
|
|
924
|
+
* The `afx send` REQUEST path calls `deliverAgentMailSerialized` directly rather than going
|
|
925
|
+
* through the drainer, so its outcome — `retryAfterMs` included — never reached
|
|
926
|
+
* {@link armInputRetry}. The visible consequence was that a send arriving while the terminal
|
|
927
|
+
* had just been typed at fell through to the quiescence trigger or the backstop, which is
|
|
928
|
+
* precisely the latency the re-drain exists to remove, in the one case an operator is sitting
|
|
929
|
+
* there watching. Measured, not theorised: the dev-approval evidence run timed that pass at
|
|
930
|
+
* the quiescence debounce rather than one settle.
|
|
931
|
+
*
|
|
932
|
+
* A no-op before the drainer is started, and idempotent per agent — the timer coalesces, so a
|
|
933
|
+
* request-path arm and a backstop arm for the same agent cannot stack.
|
|
934
|
+
*/
|
|
935
|
+
noteOutcome(workspacePath, toAgent, outcome) {
|
|
936
|
+
if (!this.ports || !this.db)
|
|
937
|
+
return;
|
|
938
|
+
this.armInputRetry(agentKey(workspacePath, toAgent), workspacePath, toAgent, outcome, this.generation);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Arm the one-shot input re-drain and track the consecutive-input-hold diagnostic
|
|
942
|
+
* (Issue #1473). Called from both pass sites, exactly where {@link recordStreak} is, so the
|
|
943
|
+
* backstop tick and the fast trigger behave identically.
|
|
944
|
+
*
|
|
945
|
+
* The retry exists because the `'submit'` fast trigger is now provably ALWAYS held: it fires
|
|
946
|
+
* synchronously from `stopComposing`, and the drain it schedules runs in a microtask, so at
|
|
947
|
+
* that pass the input timestamp is `now` by construction. Without a retry, the single most
|
|
948
|
+
* common "the line just cleared, deliver now" path would silently degrade to the 1.5 s
|
|
949
|
+
* backstop — as would any navigation key that provokes no output of its own.
|
|
950
|
+
*
|
|
951
|
+
* COALESCED: while a timer is pending for an agent, later input holds do not stack another.
|
|
952
|
+
* GENERATION-GUARDED like {@link scheduleDrain}, so a timer that survives a stop()/start()
|
|
953
|
+
* bails instead of driving the new generation's state.
|
|
954
|
+
*/
|
|
955
|
+
armInputRetry(key, workspacePath, toAgent, outcome, gen) {
|
|
956
|
+
if (outcome.retryAfterMs === undefined) {
|
|
957
|
+
this.consecutiveInputHolds.delete(key);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
// The timer serves BOTH self-clearing holds (Issue #1664 added `composer-redraw`), but the
|
|
961
|
+
// streak diagnostic below is about a human at the keyboard specifically — its warning text
|
|
962
|
+
// says so — and a repainting composer is not that. Arm the retry, break the input streak.
|
|
963
|
+
if (outcome.detail !== 'recent-input') {
|
|
964
|
+
this.consecutiveInputHolds.delete(key);
|
|
965
|
+
this.armRetryTimer(key, workspacePath, toAgent, outcome.retryAfterMs, gen);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const now = this.ports?.now() ?? Date.now();
|
|
969
|
+
// `since` is the START of the unbroken run, so the warning measures wall-clock, not passes.
|
|
970
|
+
// `warned` makes it report once at the crossing rather than on every pass past it — the
|
|
971
|
+
// streak can run for hours and the operator needs one line, not a stream.
|
|
972
|
+
const streak = this.consecutiveInputHolds.get(key) ?? { count: 0, since: now, warned: false };
|
|
973
|
+
streak.count += 1;
|
|
974
|
+
this.consecutiveInputHolds.set(key, streak);
|
|
975
|
+
const heldForMs = now - streak.since;
|
|
976
|
+
if (!streak.warned && heldForMs >= CONSECUTIVE_INPUT_HOLD_WARN_MS) {
|
|
977
|
+
streak.warned = true;
|
|
978
|
+
this.ports?.log(`[mailbox] ${toAgent} @ ${path.basename(workspacePath)} has held on recent terminal input ` +
|
|
979
|
+
`continuously for ${Math.round(heldForMs / 1000)}s (${streak.count} checks). A human types ` +
|
|
980
|
+
`in bursts, so an unbroken run this long usually means something is emitting input-shaped ` +
|
|
981
|
+
`bytes continuously — most likely a terminal reply the gate's filter does not recognise ` +
|
|
982
|
+
`(Issue #1473). Mail for this agent is NOT being delivered while this persists.`, 'WARN');
|
|
983
|
+
}
|
|
984
|
+
this.armRetryTimer(key, workspacePath, toAgent, outcome.retryAfterMs, gen);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* The one-shot re-drain timer shared by both self-clearing holds (Issue #1473's input settle
|
|
988
|
+
* and Issue #1664's composer redraw). COALESCED — while a timer is pending for an agent, a
|
|
989
|
+
* later hold does not stack another — and GENERATION-GUARDED, so a timer that survives a
|
|
990
|
+
* stop()/start() bails instead of driving the new generation's state.
|
|
991
|
+
*/
|
|
992
|
+
armRetryTimer(key, workspacePath, toAgent, retryAfterMs, gen) {
|
|
993
|
+
if (this.inputRetryTimers.has(key))
|
|
994
|
+
return; // a retry is already pending for this agent
|
|
995
|
+
const timer = setTimeout(() => {
|
|
996
|
+
this.inputRetryTimers.delete(key);
|
|
997
|
+
if (this.generation !== gen)
|
|
998
|
+
return; // stop() ran while we waited → old ports/db
|
|
999
|
+
void this.scheduleDrain(workspacePath, toAgent);
|
|
1000
|
+
}, retryAfterMs + INPUT_RETRY_MARGIN_MS);
|
|
1001
|
+
if (typeof timer.unref === 'function')
|
|
1002
|
+
timer.unref();
|
|
1003
|
+
this.inputRetryTimers.set(key, timer);
|
|
1004
|
+
}
|
|
586
1005
|
/**
|
|
587
1006
|
* Agent keys that currently hold a cached gate verdict (render-gate memo).
|
|
588
1007
|
* Observability/test only: {@link tick} prunes this to the current held-agent set, so
|
|
@@ -633,6 +1052,7 @@ export class MailboxDrainer {
|
|
|
633
1052
|
return; // stop() landed during the await → do NOT mutate the
|
|
634
1053
|
// NEW generation's freshly-cleared streak map
|
|
635
1054
|
this.recordStreak(key, outcome);
|
|
1055
|
+
this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
|
|
636
1056
|
}
|
|
637
1057
|
catch (err) {
|
|
638
1058
|
ports.log(`[mailbox] backstop delivery failed for ${toAgent}: ${String(err)}`);
|
|
@@ -837,6 +1257,7 @@ export class MailboxDrainer {
|
|
|
837
1257
|
return; // stop() landed during the await → do NOT mutate the
|
|
838
1258
|
// NEW generation's freshly-cleared streak map
|
|
839
1259
|
this.recordStreak(key, outcome);
|
|
1260
|
+
this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
|
|
840
1261
|
}
|
|
841
1262
|
catch (err) {
|
|
842
1263
|
ports.log(`[mailbox] scheduled drain failed for ${toAgent}: ${String(err)}`);
|