@cluesmith/codev 3.3.2 → 3.3.4
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-D3XaKpZW.js → index-DZf6pNPE.js} +41 -41
- package/dashboard-dist/assets/index-DZf6pNPE.js.map +1 -0
- package/dashboard-dist/assets/index-DnYLy5C_.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 +79 -10
- package/dist/agent-farm/commands/inbox.js.map +1 -1
- package/dist/agent-farm/commands/interrupt.d.ts +4 -1
- package/dist/agent-farm/commands/interrupt.d.ts.map +1 -1
- package/dist/agent-farm/commands/interrupt.js +6 -3
- package/dist/agent-farm/commands/interrupt.js.map +1 -1
- package/dist/agent-farm/commands/reset.d.ts +4 -1
- package/dist/agent-farm/commands/reset.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset.js +6 -3
- package/dist/agent-farm/commands/reset.js.map +1 -1
- package/dist/agent-farm/commands/send.d.ts +28 -0
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +79 -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 +16 -7
- 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/index.d.ts +3 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +8 -443
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/mailbox.d.ts +47 -7
- package/dist/agent-farm/db/mailbox.d.ts.map +1 -1
- package/dist/agent-farm/db/mailbox.js +56 -12
- package/dist/agent-farm/db/mailbox.js.map +1 -1
- package/dist/agent-farm/db/migrations.d.ts +57 -0
- package/dist/agent-farm/db/migrations.d.ts.map +1 -0
- package/dist/agent-farm/db/migrations.js +530 -0
- package/dist/agent-farm/db/migrations.js.map +1 -0
- package/dist/agent-farm/db/schema.d.ts +1 -1
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +1 -0
- package/dist/agent-farm/db/schema.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 +47 -0
- 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 +96 -19
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/cron-delivery.d.ts +7 -1
- package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -1
- package/dist/agent-farm/servers/cron-delivery.js +2 -1
- package/dist/agent-farm/servers/cron-delivery.js.map +1 -1
- package/dist/agent-farm/servers/gate-profiles.d.ts +44 -2
- package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
- package/dist/agent-farm/servers/gate-profiles.js +79 -6
- 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 +296 -12
- package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.js +566 -69
- package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.d.ts +66 -19
- package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.js +204 -37
- 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 +28 -12
- package/dist/agent-farm/servers/overview.d.ts.map +1 -1
- package/dist/agent-farm/servers/overview.js +130 -79
- package/dist/agent-farm/servers/overview.js.map +1 -1
- package/dist/agent-farm/servers/render-gate.d.ts +77 -1
- package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
- package/dist/agent-farm/servers/render-gate.js +174 -25
- 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 +7 -3
- 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 +17 -10
- package/dist/agent-farm/servers/tower-instances.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts +32 -0
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +186 -26
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +79 -9
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts +21 -0
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.js +106 -13
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
- package/dist/agent-farm/servers/tower-utils.d.ts +35 -0
- package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-utils.js +39 -0
- package/dist/agent-farm/servers/tower-utils.js.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.js +26 -2
- package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
- package/dist/agent-farm/utils/message-format.d.ts +78 -2
- package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
- package/dist/agent-farm/utils/message-format.js +107 -11
- 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.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +61 -1
- 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 +165 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate-image.d.ts +25 -1
- package/dist/commands/generate-image.d.ts.map +1 -1
- package/dist/commands/generate-image.js +410 -2
- package/dist/commands/generate-image.js.map +1 -1
- package/dist/commands/porch/prompts.d.ts.map +1 -1
- package/dist/commands/porch/prompts.js +18 -2
- package/dist/commands/porch/prompts.js.map +1 -1
- package/dist/lib/agent-env.d.ts +86 -0
- package/dist/lib/agent-env.d.ts.map +1 -0
- package/dist/lib/agent-env.js +126 -0
- package/dist/lib/agent-env.js.map +1 -0
- package/dist/lib/forge-contracts.d.ts +76 -5
- 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 +29 -1
- package/dist/lib/forge.d.ts.map +1 -1
- package/dist/lib/forge.js +96 -21
- 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-manager.d.ts +10 -1
- package/dist/terminal/pty-manager.d.ts.map +1 -1
- package/dist/terminal/pty-manager.js +38 -9
- package/dist/terminal/pty-manager.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +174 -8
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +299 -25
- 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/shellper-client.d.ts +73 -0
- package/dist/terminal/shellper-client.d.ts.map +1 -1
- package/dist/terminal/shellper-client.js +151 -0
- package/dist/terminal/shellper-client.js.map +1 -1
- package/dist/terminal/shellper-main.js +20 -3
- package/dist/terminal/shellper-main.js.map +1 -1
- package/dist/terminal/shellper-process.d.ts +2 -0
- package/dist/terminal/shellper-process.d.ts.map +1 -1
- package/dist/terminal/shellper-process.js +17 -1
- package/dist/terminal/shellper-process.js.map +1 -1
- package/dist/terminal/shellper-protocol.d.ts +28 -0
- package/dist/terminal/shellper-protocol.d.ts.map +1 -1
- package/dist/terminal/shellper-protocol.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/gitea/_lib.sh +214 -0
- package/scripts/forge/gitea/issue-comment.sh +8 -1
- package/scripts/forge/gitea/issue-view.sh +95 -4
- package/scripts/forge/gitea/pr-create.sh +155 -0
- package/scripts/forge/gitea/pr-exists.sh +32 -4
- package/scripts/forge/gitea/pr-list.sh +40 -31
- package/scripts/forge/gitea/pr-view.sh +65 -4
- package/scripts/forge/gitea/recently-merged.sh +91 -21
- package/scripts/forge/gitea/user-identity.sh +30 -1
- package/scripts/forge/github/issue-view.sh +5 -2
- package/scripts/forge/github/pr-create.sh +47 -0
- package/scripts/forge/github/pr-list.sh +6 -2
- package/scripts/forge/github/pr-search.sh +6 -2
- package/scripts/forge/gitlab/issue-search.sh +1 -0
- package/scripts/forge/gitlab/issue-view.sh +24 -4
- package/scripts/forge/gitlab/pr-create.sh +53 -0
- package/scripts/forge/gitlab/pr-search.sh +37 -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 +14 -2
- package/scripts/forge/linear/recently-closed.sh +1 -0
- package/scripts/forge/linear/user-identity.sh +1 -0
- package/skeleton/.claude/skills/generate-image/SKILL.md +39 -1
- package/skeleton/.codex/skills/generate-image/SKILL.md +39 -1
- package/skeleton/protocols/air/prompts/pr.md +6 -1
- package/skeleton/protocols/aspir/prompts/review.md +6 -1
- package/skeleton/protocols/bugfix/prompts/pr.md +6 -1
- package/skeleton/protocols/bugfix/protocol.md +2 -1
- package/skeleton/protocols/maintain/prompts/review.md +6 -1
- package/skeleton/protocols/pir/prompts/review.md +10 -7
- package/skeleton/protocols/spir/prompts/review.md +6 -1
- package/skeleton/resources/commands/agent-farm.md +28 -3
- package/skeleton/roles/consultant.md +32 -0
- package/skeleton/templates/AGENTS.md +1 -1
- package/skeleton/templates/CLAUDE.md +1 -1
- package/templates/tower.html +16 -1
- package/dashboard-dist/assets/index-BZRTEMtH.css +0 -32
- package/dashboard-dist/assets/index-D3XaKpZW.js.map +0 -1
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
* {@link DeliveryPorts}, so the orchestration is unit-testable without a live Tower.
|
|
34
34
|
*/
|
|
35
35
|
import path from 'node:path';
|
|
36
|
-
import { findHeldForAgent, getById, listHeld, markDelivered,
|
|
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';
|
|
39
|
+
import { formatVerdict, isUnverifiableVerdict } from '@cluesmith/codev-sdk/hold-verdict';
|
|
38
40
|
/**
|
|
39
41
|
* A gate outcome the render gate CANNOT bound to a decision — an unrecognized app
|
|
40
42
|
* (`no-profile`) or a recognized app whose composer region can't be found
|
|
@@ -44,9 +46,18 @@ import { KeyedSerializer } from './write-queue.js';
|
|
|
44
46
|
* `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line).
|
|
45
47
|
* Shared by `recordStreak` and the cooldown branch of {@link MailboxDrainer.tick} so a
|
|
46
48
|
* skipped tick and a real pass agree on what counts as classifier-stuck (CMAP round 3).
|
|
49
|
+
*
|
|
50
|
+
* This is the POLICY-side name for the same question `isUnverifiableVerdict` answers for the
|
|
51
|
+
* presentation surfaces, so it delegates rather than restating the rule (maintainer review,
|
|
52
|
+
* PR #1604). Two copies of "which verdicts never clear on their own" is one edit away from an
|
|
53
|
+
* escalation policy and an operator-facing remedy disagreeing about the same row. The wrapper
|
|
54
|
+
* is kept rather than collapsed to a single function because the two callers want different
|
|
55
|
+
* types: this one is typed on the DB/gate unions and reads naturally beside the escalation
|
|
56
|
+
* policy it serves, while the shared predicate takes the plain strings the CLI, the dashboard
|
|
57
|
+
* and the VS Code toast actually hold.
|
|
47
58
|
*/
|
|
48
59
|
function isClassifierStuck(reason, detail) {
|
|
49
|
-
return reason
|
|
60
|
+
return isUnverifiableVerdict(reason, detail);
|
|
50
61
|
}
|
|
51
62
|
/**
|
|
52
63
|
* How long the receiving session's screen must have been QUIET before a gated delivery may
|
|
@@ -74,6 +85,108 @@ export const SETTLE_BEFORE_WRITE_MS = 250;
|
|
|
74
85
|
function settled(ports, session) {
|
|
75
86
|
return ports.now() - session.lastDataAt >= SETTLE_BEFORE_WRITE_MS;
|
|
76
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
|
+
}
|
|
77
190
|
/**
|
|
78
191
|
* Minimum length of a normalized {@link echoNeedle} for it to be worth matching. Below this a
|
|
79
192
|
* needle is not distinctive enough to prove anything — a two-character raw message would match
|
|
@@ -99,6 +212,17 @@ const MIN_ECHO_NEEDLE_LENGTH = 12;
|
|
|
99
212
|
export function normalizeForEcho(text) {
|
|
100
213
|
return text.replace(/[^A-Za-z0-9]/g, '');
|
|
101
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'];
|
|
102
226
|
/**
|
|
103
227
|
* The needle {@link DeliveryPorts.watchEcho} opens on: the formatted message's FIRST line,
|
|
104
228
|
* normalized. Returns `''` when it is too short to be distinctive (see
|
|
@@ -131,16 +255,31 @@ export function agentKey(workspacePath, toAgent) {
|
|
|
131
255
|
* (Spec 1313 render-gate round 2 — the fix for the non-monotone `currentSeq:partialBytes`
|
|
132
256
|
* pair, which aliased once #1205's partial trim made `partialBytes` decrease), and the
|
|
133
257
|
* geometry catches a resize that reflows the screen without new output. So two samples that
|
|
134
|
-
* match mean the classified screen is byte-for-byte unchanged.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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.
|
|
141
280
|
*/
|
|
142
281
|
function ringToken(session, profile) {
|
|
143
|
-
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}`;
|
|
144
283
|
}
|
|
145
284
|
/** Reconstruct the delivered-message broadcast frame from a persisted row. */
|
|
146
285
|
export function broadcastForRow(row, now) {
|
|
@@ -176,12 +315,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
176
315
|
const held = findHeldForAgent(db, workspacePath, toAgent, ports.now());
|
|
177
316
|
if (held.length === 0)
|
|
178
317
|
return { delivered: [], reason: null };
|
|
179
|
-
|
|
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
|
|
321
|
+
// previous detail in place would let `afx inbox` keep asserting "a human is at the composer"
|
|
322
|
+
// about a session whose PTY has since died.
|
|
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) => {
|
|
180
332
|
for (const row of held) {
|
|
181
|
-
if (row.reason !== reason)
|
|
182
|
-
|
|
333
|
+
if (row.reason !== reason || row.detail !== detail)
|
|
334
|
+
setHeldVerdict(db, row.id, reason, detail, ports.now());
|
|
183
335
|
}
|
|
184
|
-
return { delivered: [], reason };
|
|
336
|
+
return { delivered: [], reason, ...(detail ? { detail } : {}) };
|
|
185
337
|
};
|
|
186
338
|
const session = ports.getSessionForAgent(workspacePath, toAgent);
|
|
187
339
|
if (!session)
|
|
@@ -189,17 +341,26 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
189
341
|
const profile = ports.resolveProfile(session);
|
|
190
342
|
if (!profile)
|
|
191
343
|
return hold('no-profile');
|
|
192
|
-
|
|
193
|
-
|
|
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).
|
|
194
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;
|
|
195
357
|
// Verdict memo (Spec 1313 render-gate follow-up). The 1.5 s backstop re-checks every held
|
|
196
358
|
// agent's screen each tick; for a STATIC screen that classify is pure waste. Reuse the
|
|
197
359
|
// cached verdict while BOTH the live session instance AND the token are unchanged — the token
|
|
198
360
|
// advances on ANY new output, so a match means the screen is byte-for-byte what we already
|
|
199
361
|
// classified, and the session guard closes the PTY-respawn aliasing route (a replacement
|
|
200
362
|
// session is a DIFFERENT object); the monotone token closes the old `RingBuffer.clear()` route
|
|
201
|
-
// (see {@link CachedVerdict}). A memo hit does NO await, so
|
|
202
|
-
// 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
|
|
203
364
|
// classify window that never opened. The memo is owned + bounded by the drainer's backstop
|
|
204
365
|
// {@link MailboxDrainer.tick} (pruned to the held-agent set each tick); every OTHER caller —
|
|
205
366
|
// the request/cron paths and the fast scheduleDrain trigger — passes none and classifies
|
|
@@ -218,27 +379,72 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
218
379
|
// Carry the gate detail so a sustained classifier-stuck streak (a drifted profile
|
|
219
380
|
// or an unrenderable frame) escalates to liveness telemetry instead of holding silently.
|
|
220
381
|
const reason = verdict.reason ?? 'busy';
|
|
382
|
+
// `empty` is the CLEAN detail and cannot reach here (a clean verdict does not take this
|
|
383
|
+
// branch); narrowing it away keeps the persisted set to the three not-clean values the
|
|
384
|
+
// column's type admits.
|
|
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);
|
|
221
393
|
for (const row of held) {
|
|
222
|
-
if (row.reason !== reason)
|
|
223
|
-
|
|
394
|
+
if (row.reason !== reason || row.detail !== detail) {
|
|
395
|
+
setHeldVerdict(db, row.id, reason, detail, ports.now());
|
|
396
|
+
}
|
|
224
397
|
}
|
|
225
398
|
return { delivered: [], reason, detail: verdict.detail };
|
|
226
399
|
}
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
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();
|
|
242
448
|
// Clean, verified-empty prompt → deliver the oldest held message. Await the
|
|
243
449
|
// write's paced completion so a serialized follow-up delivery never begins
|
|
244
450
|
// until this message's text + Enter is fully on the wire.
|
|
@@ -272,18 +478,81 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
272
478
|
// `--interrupt`/`--escape` had just cleared. Cheap enough to repeat — a synchronous ring
|
|
273
479
|
// read and one indexed better-sqlite3 lookup.
|
|
274
480
|
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// human's keystrokes over the WebSocket
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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.
|
|
280
524
|
const precheck = () => {
|
|
281
525
|
if (!session.writable)
|
|
282
526
|
return { kind: 'hold', reason: 'no-live-pty' };
|
|
283
|
-
if (
|
|
284
|
-
return { kind: 'hold', reason: 'busy' };
|
|
285
|
-
|
|
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)) {
|
|
286
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
|
+
}
|
|
287
556
|
const stillHeld = getById(db, row.id);
|
|
288
557
|
if (!stillHeld || stillHeld.status !== 'held')
|
|
289
558
|
return { kind: 'row-resolved' };
|
|
@@ -299,7 +568,7 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
299
568
|
// delivery); the try either assigns the real result or throws past this point.
|
|
300
569
|
let result = { status: 'aborted', abort: { kind: 'hold', reason: 'busy' } };
|
|
301
570
|
try {
|
|
302
|
-
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));
|
|
303
572
|
}
|
|
304
573
|
finally {
|
|
305
574
|
// Invalidate the memo on EVERY write outcome — a clean `true`, a dropped-write `false`, OR a
|
|
@@ -313,11 +582,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
313
582
|
// putting SOME bytes on the wire, e.g. the text landed but the Enter dropped → we hold below;
|
|
314
583
|
// (c) writeMessage REJECTS after partial bytes — its port contract (`boolean | Promise<boolean>`)
|
|
315
584
|
// permits a binding to throw, and a bare throw would skip a delete placed after the await. In
|
|
316
|
-
// every case a leftover CLEAN would let a follow-up held message memo-hit the SAME token
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// 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.
|
|
320
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);
|
|
321
604
|
}
|
|
322
605
|
// Anything short of a complete submit holds the row — a delivery is marked delivered only when
|
|
323
606
|
// every byte, Enter included, reached the terminal.
|
|
@@ -357,27 +640,39 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
357
640
|
ports.onHeldStateChange(); // the held set changed under us → refresh the indicator
|
|
358
641
|
return { delivered: [], reason: null };
|
|
359
642
|
}
|
|
360
|
-
|
|
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 };
|
|
361
650
|
}
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
651
|
+
// THE POINT OF NO RETURN (Issue #1584). Past this line the write COMPLETED — every byte,
|
|
652
|
+
// Enter included, was accepted by the session — so the row is at-least-once delivered and
|
|
653
|
+
// must NEVER be written again. Nothing below may `hold(...)`: a hold puts the row back in
|
|
654
|
+
// the drainer's held set, and the next clean-prompt pass re-writes the WHOLE message with
|
|
655
|
+
// no attempt cap anywhere in this module. That is exactly what #1583 saw in the field —
|
|
656
|
+
// one `afx send --file` re-injected dozens of times, byte-identical, silently (a `busy`
|
|
657
|
+
// hold is excluded from {@link isClassifierStuck}, so no streak ever escalates it).
|
|
366
658
|
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
659
|
+
// Only PRE-write prechecks may hold, and every hold above this point is one: the branches
|
|
660
|
+
// between the write and here wrote NOTHING (`contended`, `aborted`) or produced a write that
|
|
661
|
+
// cannot be trusted to have landed (`dropped` — bytes lost mid-pace to a dead socket;
|
|
662
|
+
// `preempted` — an unserialized operator submission may have cleared or truncated the
|
|
663
|
+
// composer under them). Only `written` reaches this line, and only `written` is the
|
|
664
|
+
// at-least-once guarantee that forbids a re-write.
|
|
665
|
+
//
|
|
666
|
+
// COMMIT THE DELIVERY FIRST, before any further await or fallible call (CMAP round 1 — Codex).
|
|
667
|
+
// The point of no return is only real if it is DURABLE: while the row still reads `held` in
|
|
668
|
+
// the database it is re-writable by the next drainer tick, so leaving it held across ~1.2 s of
|
|
669
|
+
// verification would reopen the loop for any interruption of that window — a Tower crash or
|
|
670
|
+
// restart, or a `verify()` that rejects instead of answering. `markDelivered` is synchronous
|
|
671
|
+
// (better-sqlite3), so ordering it here leaves no window at all.
|
|
672
|
+
//
|
|
673
|
+
// markDelivered is guarded (held→delivered only). If it did NOT transition, the row was
|
|
674
|
+
// dismissed/superseded during the paced write — accept that terminal state and do not
|
|
675
|
+
// broadcast a delivery for it.
|
|
381
676
|
if (!markDelivered(db, row.id, ports.now())) {
|
|
382
677
|
ports.onHeldStateChange();
|
|
383
678
|
return { delivered: [], reason: null };
|
|
@@ -385,7 +680,72 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
|
|
|
385
680
|
ports.broadcast(broadcastForRow(current, ports.now()));
|
|
386
681
|
ports.onHeldStateChange(); // a held row left the set → refresh the indicator count
|
|
387
682
|
ports.log(`[mailbox] delivered ${row.id} → ${toAgent} @ ${path.basename(workspacePath)}`);
|
|
388
|
-
|
|
683
|
+
// Echo verification (Issue #1573) still runs — it is the only end-to-end evidence the bytes
|
|
684
|
+
// reached the terminal — but it is now pure REPORTING, downstream of a delivery that is
|
|
685
|
+
// already committed. `false` means "could not confirm", never "definitely lost".
|
|
686
|
+
let verified;
|
|
687
|
+
if (echo) {
|
|
688
|
+
// One bounded RE-verify window before giving up. `verify()` polls to its own deadline and
|
|
689
|
+
// then answers false; a second call opens a fresh window against the SAME pre-write sample,
|
|
690
|
+
// so it still requires evidence THIS write produced. It accommodates a slow renderer
|
|
691
|
+
// without writing a single byte, and the total stays inside the sender's patience (~1.5 s)
|
|
692
|
+
// because the request path awaits this.
|
|
693
|
+
//
|
|
694
|
+
// A REJECTION is unconfirmed, not an error to propagate: the bytes are out and the row is
|
|
695
|
+
// committed, so throwing here would only deny the sender the `verified` answer and log a
|
|
696
|
+
// spurious delivery failure.
|
|
697
|
+
try {
|
|
698
|
+
verified = (await echo.verify()) || (await echo.verify());
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
verified = false;
|
|
702
|
+
ports.log(`[mailbox] echo verification errored for ${row.id.slice(0, 8)}…: ${String(err)}`, 'WARN');
|
|
703
|
+
}
|
|
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
|
+
};
|
|
747
|
+
}
|
|
748
|
+
return { delivered: [row.id], reason: null, ...(verified === undefined ? {} : { verified }) };
|
|
389
749
|
}
|
|
390
750
|
/**
|
|
391
751
|
* Shared per-agent delivery serializer (Spec 1313, Phase 4). Every live caller —
|
|
@@ -428,6 +788,26 @@ const LIVENESS_STREAK_THRESHOLD = 10;
|
|
|
428
788
|
// spam owners. Derived from escalationMs (default 60s → 180s), so it scales with the
|
|
429
789
|
// configured `mailbox.escalationSeconds` without a separate config knob.
|
|
430
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;
|
|
431
811
|
/**
|
|
432
812
|
* The poll backstop that replaces `SendBuffer`'s flush timer. On each tick it walks
|
|
433
813
|
* every agent with held mail and runs {@link deliverAgentMail}, so a message held
|
|
@@ -464,6 +844,14 @@ export class MailboxDrainer {
|
|
|
464
844
|
// with the persistent bounded mirror, a classify is O(viewport) regardless of history, so there
|
|
465
845
|
// is no expensive whole-ring render left to throttle — every tick just re-classifies cheaply.)
|
|
466
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();
|
|
467
855
|
// Lifecycle generation (CMAP round 2 — Codex/Claude): the drainer instance is REUSED across
|
|
468
856
|
// stop()/start() (mailbox-wiring `ensureDrainer`), and the tests do start/stop/start. Bumped on
|
|
469
857
|
// stop() so an in-flight tick/scheduleDrain that resumes after a restart bails before mutating
|
|
@@ -509,12 +897,111 @@ export class MailboxDrainer {
|
|
|
509
897
|
this.notCleanStreak.clear();
|
|
510
898
|
this.scheduledDrains.clear();
|
|
511
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();
|
|
512
907
|
this.generation++;
|
|
513
908
|
}
|
|
514
909
|
/** Per-agent consecutive not-clean count (liveness telemetry; Phase 7 reads this). */
|
|
515
910
|
get streaks() {
|
|
516
911
|
return this.notCleanStreak;
|
|
517
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
|
+
}
|
|
518
1005
|
/**
|
|
519
1006
|
* Agent keys that currently hold a cached gate verdict (render-gate memo).
|
|
520
1007
|
* Observability/test only: {@link tick} prunes this to the current held-agent set, so
|
|
@@ -565,6 +1052,7 @@ export class MailboxDrainer {
|
|
|
565
1052
|
return; // stop() landed during the await → do NOT mutate the
|
|
566
1053
|
// NEW generation's freshly-cleared streak map
|
|
567
1054
|
this.recordStreak(key, outcome);
|
|
1055
|
+
this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
|
|
568
1056
|
}
|
|
569
1057
|
catch (err) {
|
|
570
1058
|
ports.log(`[mailbox] backstop delivery failed for ${toAgent}: ${String(err)}`);
|
|
@@ -612,9 +1100,10 @@ export class MailboxDrainer {
|
|
|
612
1100
|
mailboxId: row.id,
|
|
613
1101
|
ageMs,
|
|
614
1102
|
reason: row.reason,
|
|
1103
|
+
detail: row.detail,
|
|
615
1104
|
});
|
|
616
1105
|
ports.log(`[mailbox] ESCALATED ${row.id.slice(0, 8)}… → ${row.to_agent} @ ${path.basename(row.workspace_path)} ` +
|
|
617
|
-
`(held ${Math.round(ageMs / 1000)}s, reason ${row.reason
|
|
1106
|
+
`(held ${Math.round(ageMs / 1000)}s, reason ${formatVerdict(row.reason, row.detail)}) — visibility only, not delivered`);
|
|
618
1107
|
}
|
|
619
1108
|
// A row's escalated flag flipped → the overview-derived `mailboxEscalated` attention
|
|
620
1109
|
// bit changed. Fire the held-state-change event too (in addition to the per-row
|
|
@@ -664,8 +1153,15 @@ export class MailboxDrainer {
|
|
|
664
1153
|
workspacePath: agent.workspacePath,
|
|
665
1154
|
toAgent: agent.toAgent,
|
|
666
1155
|
reason: agent.reason,
|
|
1156
|
+
// Issue #1482: the gate detail decides which notice the owner gets, and the streak
|
|
1157
|
+
// says how many consecutive checks confirmed it. Both are already tracked — the
|
|
1158
|
+
// detail on the row (aggregated by findStarvingAgents), the streak in the drainer's
|
|
1159
|
+
// own map — so this costs no new state, just carrying what we have to where the
|
|
1160
|
+
// human reads it.
|
|
1161
|
+
detail: agent.detail,
|
|
667
1162
|
ageMs: now - agent.stuckSince,
|
|
668
1163
|
heldCount: agent.count,
|
|
1164
|
+
streak: this.notCleanStreak.get(key) ?? 0,
|
|
669
1165
|
});
|
|
670
1166
|
if (enqueued)
|
|
671
1167
|
this.notifiedAgents.add(key);
|
|
@@ -761,6 +1257,7 @@ export class MailboxDrainer {
|
|
|
761
1257
|
return; // stop() landed during the await → do NOT mutate the
|
|
762
1258
|
// NEW generation's freshly-cleared streak map
|
|
763
1259
|
this.recordStreak(key, outcome);
|
|
1260
|
+
this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
|
|
764
1261
|
}
|
|
765
1262
|
catch (err) {
|
|
766
1263
|
ports.log(`[mailbox] scheduled drain failed for ${toAgent}: ${String(err)}`);
|