@agfpd/iapeer 0.4.98 → 0.4.100
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/package.json +1 -1
- package/src/daemon/mutewatch.ts +4 -29
- package/src/daemon/sessionfiles.ts +121 -0
- package/src/daemon/turnwatch.ts +4 -29
- package/src/launch/ptyHost.ts +20 -1
- package/src/transport/index.ts +48 -11
package/package.json
CHANGED
package/src/daemon/mutewatch.ts
CHANGED
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
// the `cwd` field on every line; codex: session_meta.payload.cwd), and match that against the
|
|
46
46
|
// registry. A file whose cwd is no peer's is ignored — a human's own session never notifies.
|
|
47
47
|
|
|
48
|
-
import { existsSync,
|
|
48
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
49
49
|
import { join } from 'node:path'
|
|
50
50
|
import type { LifecycleConfig } from '../lifecycle/index.ts'
|
|
51
51
|
import type { NoticeBoard } from './notices.ts'
|
|
52
|
+
import { readSessionSlices } from './sessionfiles.ts'
|
|
52
53
|
|
|
53
54
|
/** How much of a session file's tail is parsed. The evidence is always at the end. */
|
|
54
55
|
const TAIL_BYTES = 256 * 1024
|
|
@@ -229,32 +230,6 @@ export function parseCodexRollout(text: string, sinceMs: number, head = ''): Raw
|
|
|
229
230
|
// The sweep — find changed session files, attribute them, raise notices
|
|
230
231
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
232
|
|
|
232
|
-
function readTail(path: string, maxBytes = TAIL_BYTES): { tail: string; head: string } | null {
|
|
233
|
-
try {
|
|
234
|
-
const fd = openSync(path, 'r')
|
|
235
|
-
try {
|
|
236
|
-
const size = fstatSync(fd).size
|
|
237
|
-
if (size <= 0) return null
|
|
238
|
-
const off = Math.max(0, size - maxBytes)
|
|
239
|
-
const buf = Buffer.alloc(size - off)
|
|
240
|
-
readSync(fd, buf, 0, buf.length, off)
|
|
241
|
-
let head = ''
|
|
242
|
-
if (off > 0) {
|
|
243
|
-
// The tail cut away the file's start — read a HEAD slice too (codex session_meta
|
|
244
|
-
// is line 1, and a long session pushes it far out of the tail).
|
|
245
|
-
const hb = Buffer.alloc(Math.min(64 * 1024, off))
|
|
246
|
-
readSync(fd, hb, 0, hb.length, 0)
|
|
247
|
-
head = hb.toString('utf8')
|
|
248
|
-
}
|
|
249
|
-
return { tail: buf.toString('utf8'), head }
|
|
250
|
-
} finally {
|
|
251
|
-
closeSync(fd)
|
|
252
|
-
}
|
|
253
|
-
} catch {
|
|
254
|
-
return null
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
233
|
function safeReaddir(dir: string): string[] {
|
|
259
234
|
try {
|
|
260
235
|
return readdirSync(dir)
|
|
@@ -336,12 +311,12 @@ export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[]
|
|
|
336
311
|
}
|
|
337
312
|
// claude: ~/.claude/projects/<slug>/<sessionId>.jsonl — one level of slug dirs.
|
|
338
313
|
for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
|
|
339
|
-
const t =
|
|
314
|
+
const t = readSessionSlices(file, TAIL_BYTES)
|
|
340
315
|
if (t) push(parseClaudeTranscript(t.tail, floor), 'claude')
|
|
341
316
|
}
|
|
342
317
|
// codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
343
318
|
for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
|
|
344
|
-
const t =
|
|
319
|
+
const t = readSessionSlices(file, TAIL_BYTES)
|
|
345
320
|
if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
|
|
346
321
|
}
|
|
347
322
|
return out
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Session-file reading — the ONE place that knows how to get evidence out of a runtime's own
|
|
2
|
+
// session JSONL (claude transcript / codex rollout). Shared by every cwd-attributed detector:
|
|
3
|
+
// mutewatch (docs/19), turnwatch (docs/15 §Activity).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS MODULE EXISTS — a bug, measured on real bytes 16.07.2026.
|
|
6
|
+
//
|
|
7
|
+
// Both detectors need two slices of a session file: the TAIL (where the evidence is) and, for
|
|
8
|
+
// codex, the HEAD (where `session_meta` states the cwd that attributes the file to a peer). The
|
|
9
|
+
// head was read as a FIXED 64 KB slice. That is wrong, and it failed silently:
|
|
10
|
+
//
|
|
11
|
+
// `session_meta` is ONE line, and it carries the session's composed doctrine — so its size
|
|
12
|
+
// scales with the peer's own instructions. Measured on this host: 45 KB for one peer, 65 KB
|
|
13
|
+
// for another, and **79 KB** for another. A 64 KB slice cuts that line mid-JSON, the parse
|
|
14
|
+
// throws, no cwd is ever found, and the file is treated as unattributable — i.e. as a human's
|
|
15
|
+
// own session. The peer VANISHES from the detector entirely.
|
|
16
|
+
//
|
|
17
|
+
// The failure mode is the nastiest kind: no error, no log, no partial result — a real peer that
|
|
18
|
+
// simply never appears, and the bigger its doctrine the more certainly it disappears. It was
|
|
19
|
+
// invisible in tests because fixtures have small metas; it surfaced only against the live tree,
|
|
20
|
+
// where the peer with the largest doctrine was the one silently missing.
|
|
21
|
+
//
|
|
22
|
+
// THE RULE: never slice a line at a fixed size to parse it. Read the LINE. `readFirstLine`
|
|
23
|
+
// scans forward until the first newline (bounded), so it is correct at any doctrine size and
|
|
24
|
+
// costs one extra chunk read only for the files that need it.
|
|
25
|
+
|
|
26
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
27
|
+
|
|
28
|
+
/** Chunk size for the forward scan. One read covers the common case. */
|
|
29
|
+
const HEAD_CHUNK_BYTES = 64 * 1024
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upper bound on the first line. Generous on purpose — a doctrine can grow, and the cost of
|
|
33
|
+
* being wrong is a peer disappearing, while the cost of a larger bound is one read of a file
|
|
34
|
+
* we are already reading. A line longer than this yields '' (unattributable) rather than a
|
|
35
|
+
* truncated parse that would lie.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_FIRST_LINE_BYTES = 4 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The file's FIRST LINE (without the trailing newline), read forward in bounded chunks.
|
|
41
|
+
* Returns '' if the bound is hit before a newline — the honest "cannot read it" rather than a
|
|
42
|
+
* fragment. Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export function readFirstLine(path: string, maxBytes = MAX_FIRST_LINE_BYTES): string {
|
|
45
|
+
let fd: number | null = null
|
|
46
|
+
try {
|
|
47
|
+
fd = openSync(path, 'r')
|
|
48
|
+
const size = fstatSync(fd).size
|
|
49
|
+
if (size <= 0) return ''
|
|
50
|
+
const limit = Math.min(size, maxBytes)
|
|
51
|
+
let acc = ''
|
|
52
|
+
let off = 0
|
|
53
|
+
while (off < limit) {
|
|
54
|
+
const want = Math.min(HEAD_CHUNK_BYTES, limit - off)
|
|
55
|
+
const buf = Buffer.alloc(want)
|
|
56
|
+
const got = readSync(fd, buf, 0, want, off)
|
|
57
|
+
if (got <= 0) break
|
|
58
|
+
const chunk = buf.subarray(0, got).toString('utf8')
|
|
59
|
+
const nl = chunk.indexOf('\n')
|
|
60
|
+
if (nl >= 0) return acc + chunk.slice(0, nl)
|
|
61
|
+
acc += chunk
|
|
62
|
+
off += got
|
|
63
|
+
}
|
|
64
|
+
// Reached EOF within the bound with no newline: the whole file IS one line — return it.
|
|
65
|
+
// (`off < limit` above only exits early when we hit the bound BEFORE EOF; a file smaller
|
|
66
|
+
// than the bound falls through to here with its complete single line in `acc`.)
|
|
67
|
+
if (off >= size) return acc
|
|
68
|
+
return '' // hit the bound before EOF → refuse to hand back a fragment of a longer line
|
|
69
|
+
} catch {
|
|
70
|
+
return ''
|
|
71
|
+
} finally {
|
|
72
|
+
if (fd !== null) {
|
|
73
|
+
try {
|
|
74
|
+
closeSync(fd)
|
|
75
|
+
} catch {
|
|
76
|
+
/* best-effort */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SessionSlices {
|
|
83
|
+
/** The last `tailBytes` of the file — where every detector's evidence lives. */
|
|
84
|
+
tail: string
|
|
85
|
+
/** The file's first line, when the tail did not already contain the file start. Empty
|
|
86
|
+
* otherwise (the tail already has it) or when it could not be read. */
|
|
87
|
+
head: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a session file's tail plus, when the tail cut the start away, its first line.
|
|
92
|
+
* Returns null for an empty/unreadable file. Never throws — a detector sweep must survive any
|
|
93
|
+
* file on disk.
|
|
94
|
+
*/
|
|
95
|
+
export function readSessionSlices(path: string, tailBytes: number): SessionSlices | null {
|
|
96
|
+
let fd: number | null = null
|
|
97
|
+
try {
|
|
98
|
+
fd = openSync(path, 'r')
|
|
99
|
+
const size = fstatSync(fd).size
|
|
100
|
+
if (size <= 0) return null
|
|
101
|
+
const off = Math.max(0, size - tailBytes)
|
|
102
|
+
const buf = Buffer.alloc(size - off)
|
|
103
|
+
readSync(fd, buf, 0, buf.length, off)
|
|
104
|
+
const tail = buf.toString('utf8')
|
|
105
|
+
closeSync(fd)
|
|
106
|
+
fd = null
|
|
107
|
+
// Only when the tail cut the start away does the first line need its own read.
|
|
108
|
+
const head = off > 0 ? readFirstLine(path) : ''
|
|
109
|
+
return { tail, head }
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
} finally {
|
|
113
|
+
if (fd !== null) {
|
|
114
|
+
try {
|
|
115
|
+
closeSync(fd)
|
|
116
|
+
} catch {
|
|
117
|
+
/* best-effort */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/daemon/turnwatch.ts
CHANGED
|
@@ -56,15 +56,14 @@
|
|
|
56
56
|
// IDLE codex pane-log freezes (39s→46s). The signal means opposite things per runtime; one
|
|
57
57
|
// clock that lies differently on each runtime is worse than no clock.
|
|
58
58
|
|
|
59
|
-
import { existsSync,
|
|
59
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
60
60
|
import { join } from 'node:path'
|
|
61
61
|
import type { LifecycleConfig } from '../lifecycle/index.ts'
|
|
62
62
|
import { appendTurnEvent } from './turnslog.ts'
|
|
63
|
+
import { readSessionSlices } from './sessionfiles.ts'
|
|
63
64
|
|
|
64
65
|
/** Tail parsed per session file. The turn marker is always at the very end. */
|
|
65
66
|
const TAIL_BYTES = 128 * 1024
|
|
66
|
-
/** Head slice for codex's `session_meta` (line 1 — a long rollout pushes it out of the tail). */
|
|
67
|
-
const HEAD_BYTES = 64 * 1024
|
|
68
67
|
/** Walk depth for the codex YYYY/MM/DD tree. */
|
|
69
68
|
const CODEX_WALK_DEPTH = 3
|
|
70
69
|
/** How far back the FIRST sweep looks, to seed state for sessions already running when the
|
|
@@ -238,30 +237,6 @@ export class ActivityBoard {
|
|
|
238
237
|
// The sweep
|
|
239
238
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
239
|
|
|
241
|
-
function readTail(path: string): { tail: string; head: string } | null {
|
|
242
|
-
try {
|
|
243
|
-
const fd = openSync(path, 'r')
|
|
244
|
-
try {
|
|
245
|
-
const size = fstatSync(fd).size
|
|
246
|
-
if (size <= 0) return null
|
|
247
|
-
const off = Math.max(0, size - TAIL_BYTES)
|
|
248
|
-
const buf = Buffer.alloc(size - off)
|
|
249
|
-
readSync(fd, buf, 0, buf.length, off)
|
|
250
|
-
let head = ''
|
|
251
|
-
if (off > 0) {
|
|
252
|
-
const hb = Buffer.alloc(Math.min(HEAD_BYTES, off))
|
|
253
|
-
readSync(fd, hb, 0, hb.length, 0)
|
|
254
|
-
head = hb.toString('utf8')
|
|
255
|
-
}
|
|
256
|
-
return { tail: buf.toString('utf8'), head }
|
|
257
|
-
} finally {
|
|
258
|
-
closeSync(fd)
|
|
259
|
-
}
|
|
260
|
-
} catch {
|
|
261
|
-
return null
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
240
|
function safeReaddir(dir: string): string[] {
|
|
266
241
|
try {
|
|
267
242
|
return readdirSync(dir)
|
|
@@ -335,11 +310,11 @@ export function scanTurnStates(deps: TurnScanDeps, sinceMs: number): TurnObserva
|
|
|
335
310
|
out.push({ ...st, personality, runtime })
|
|
336
311
|
}
|
|
337
312
|
for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
|
|
338
|
-
const t =
|
|
313
|
+
const t = readSessionSlices(file, TAIL_BYTES)
|
|
339
314
|
if (t) push(parseClaudeTurn(t.tail), 'claude')
|
|
340
315
|
}
|
|
341
316
|
for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
|
|
342
|
-
const t =
|
|
317
|
+
const t = readSessionSlices(file, TAIL_BYTES)
|
|
343
318
|
if (t) push(parseCodexTurn(t.tail, t.head), 'codex')
|
|
344
319
|
}
|
|
345
320
|
return out
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// - sessionAlive comes from client.ts (no @xterm);
|
|
12
12
|
// - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
-
import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
14
|
+
import { deliverToHost, sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
15
|
import { attachedPath, defaultRunDir, pidPath, readGeometry } from '../supervisor/paths.ts'
|
|
16
16
|
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
17
|
import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
|
|
@@ -177,3 +177,22 @@ export async function waitHostReady(
|
|
|
177
177
|
export function deliverHosted(identity: string, message: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
178
178
|
return deliverToHost(hostRunDir(env), identity, message)
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Re-send a BARE CR to a hosted session — the submit-retry behind the landed-confirm grace
|
|
183
|
+
* (transport/index.ts). NOT a delivery: it pastes nothing and carries no payload, it only presses
|
|
184
|
+
* Enter on whatever the composer already holds.
|
|
185
|
+
*
|
|
186
|
+
* WHY THIS EXISTS. deliverToHost is paste → 300 ms settle → CR. When the paste carries attachments
|
|
187
|
+
* the receiving TUI hoists them (reads + encodes the files, rewrites the composer), and the CR fired
|
|
188
|
+
* on the fixed settle lands MID-HOIST and is swallowed — the composer keeps the envelope and NO turn
|
|
189
|
+
* is ever submitted. Proven live 16.07.2026 on an idle claude receiver: att=4 (2.7 MB) sat unsubmitted
|
|
190
|
+
* with a frozen transcript for 3m33s and moved only when an unrelated later delivery's CR arrived; the
|
|
191
|
+
* SAME envelope with an 8 s settle submitted itself in 4 s (single-factor isolation).
|
|
192
|
+
*
|
|
193
|
+
* Sent through the CONTROL channel (bare bytes, no bracketed paste) precisely because it must be a
|
|
194
|
+
* keystroke, not a pasted literal.
|
|
195
|
+
*/
|
|
196
|
+
export function resubmitHosted(identity: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
197
|
+
return sendControlToHost(hostRunDir(env), identity, [Buffer.from('\r', 'utf8')])
|
|
198
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
hostSessionAlive,
|
|
56
56
|
hostSessionToken,
|
|
57
57
|
listHostedPeers,
|
|
58
|
+
resubmitHosted,
|
|
58
59
|
} from '../launch/ptyHost.ts'
|
|
59
60
|
import { sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
60
61
|
// Origin-guard (docs/18) — reply-channel mechanic for agent→human sends. Lives in
|
|
@@ -183,20 +184,39 @@ const LIVENESS_POLL_MS = 100
|
|
|
183
184
|
// deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent loss, forbidden).
|
|
184
185
|
const CONFIRM_GRACE_MS = 4000
|
|
185
186
|
|
|
186
|
-
// ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// and the receiver's first output came at 19:35:21.7. The receiver writes the user-record when it
|
|
190
|
-
// BEGINS the turn, and loading/encoding the images pushed that start ~22 s out — so a 4 s grace
|
|
191
|
-
// expired on a message that had landed. Every att>0 delivery to a claude receiver false-FAILED,
|
|
192
|
-
// the sender retried on the lie, and the message duplicated in the receiver's context.
|
|
187
|
+
// ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments: an att>0 paste
|
|
188
|
+
// makes the receiving TUI hoist the files (read + encode, rewrite the composer) before any turn can
|
|
189
|
+
// begin, which takes seconds.
|
|
193
190
|
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
191
|
+
// CORRECTED 16.07.2026 — the model this number was first built on was WRONG. The original note here
|
|
192
|
+
// read the 15.07 att=4 measurement as "the receiver writes the user-record when it BEGINS the turn,
|
|
193
|
+
// and loading the images pushed that start ~22 s out", i.e. a LATE SELF-START that a longer grace
|
|
194
|
+
// would catch. A confound-free repro disproved it: att=4 (2.7 MB) into a claude receiver idle 2m17s,
|
|
195
|
+
// with NOTHING else sent, left the transcript byte-frozen for 180 s — the turn NEVER started. The
|
|
196
|
+
// 15.07 "22 s self-start" was almost certainly the same confound (an unrelated later delivery poked
|
|
197
|
+
// the session). The real mechanism is a SWALLOWED SUBMIT, not a slow write: deliverToHost is
|
|
198
|
+
// paste → 300 ms settle → CR, and with attachments the CR lands mid-hoist and is eaten. Proven by
|
|
199
|
+
// single-factor isolation: the SAME envelope with an 8 s settle submitted itself in 4 s.
|
|
200
|
+
//
|
|
201
|
+
// So a passive grace of ANY size is useless here — it waits for a record that cannot exist, because
|
|
202
|
+
// nothing was ever submitted. What makes the grace meaningful is the SUBMIT-RETRY below: we keep
|
|
203
|
+
// pressing Enter while no record has landed, so the CR that falls after the hoist finishes does the
|
|
204
|
+
// work. 60 s is then an upper BOUND on hoisting a large payload (not a calibrated latency), and it
|
|
205
|
+
// costs nothing on the happy path — the confirm returns the moment the record lands.
|
|
198
206
|
const CONFIRM_GRACE_ATTACHMENTS_MS = 60_000
|
|
199
207
|
|
|
208
|
+
// Submit-retry cadence — how often, WHILE NO RECORD HAS LANDED, the grace re-presses Enter on the
|
|
209
|
+
// receiver's composer (see the correction above; the primitive is ptyHost.resubmitHosted).
|
|
210
|
+
//
|
|
211
|
+
// WHY THIS IS SAFE. A bare CR reaches the composer in exactly one of two states: (a) it still holds
|
|
212
|
+
// our unsubmitted paste — the CR submits it, which is the entire point; or (b) it is empty because
|
|
213
|
+
// the turn already submitted — an empty composer ignores a CR. The loop is gated on `confirm()`
|
|
214
|
+
// being false, so we stop the instant the record appears.
|
|
215
|
+
//
|
|
216
|
+
// Deliberately NOT every poll (100 ms): a CR is a keystroke into a live human-visible session, so it
|
|
217
|
+
// is paced. 1.5 s is far below any plausible hoist time yet spaces the presses.
|
|
218
|
+
const RESUBMIT_INTERVAL_MS = 1500
|
|
219
|
+
|
|
200
220
|
function envNonNegative(raw: string | undefined): number | null {
|
|
201
221
|
const n = raw === undefined ? NaN : Number(raw)
|
|
202
222
|
return Number.isFinite(n) && n >= 0 ? n : null
|
|
@@ -246,6 +266,10 @@ export interface WarmDeliverSeam {
|
|
|
246
266
|
* loop (the grace is up to several seconds for codex, so a sync block would stall the whole router
|
|
247
267
|
* daemon for the duration of one delivery). */
|
|
248
268
|
sleep?: (ms: number) => Promise<void>
|
|
269
|
+
/** SUBMIT-RETRY: press Enter again on the receiver's composer while no record has landed. Default:
|
|
270
|
+
* the real `resubmitHosted` (a bare CR over the control channel). Tests inject a spy to assert the
|
|
271
|
+
* retry fires only while unconfirmed — and never once the record is in. */
|
|
272
|
+
resubmit?: (identity: string) => Promise<void>
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
/**
|
|
@@ -357,11 +381,24 @@ async function deliverViaHost(
|
|
|
357
381
|
// false-OK (silent loss the contract forbids).
|
|
358
382
|
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
|
|
359
383
|
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
384
|
+
const resubmit = seam.resubmit ?? (async (identity: string) => void (await resubmitHosted(identity, env)))
|
|
360
385
|
const graceMs = confirmGraceMs(envelopeHasAttachments(envelope))
|
|
361
386
|
const graceDeadline = monotonicMs() + graceMs
|
|
387
|
+
// SUBMIT-RETRY (see RESUBMIT_INTERVAL_MS). deliverToHost already pressed Enter once, on a fixed
|
|
388
|
+
// settle; if the paste was still hoisting, that CR was eaten and NOTHING is running. So the grace
|
|
389
|
+
// does not merely WAIT for a record — while none has landed it keeps pressing Enter, and whichever
|
|
390
|
+
// press falls after the hoist submits the composer. Self-correcting for any payload weight: there is
|
|
391
|
+
// deliberately NO tuned "how long does hoisting take" constant anywhere in this path.
|
|
392
|
+
let nextResubmitAt = monotonicMs() + RESUBMIT_INTERVAL_MS
|
|
362
393
|
while (true) {
|
|
363
394
|
if (confirm(envelope)) return ok(undefined)
|
|
364
395
|
if (monotonicMs() >= graceDeadline) break
|
|
396
|
+
if (monotonicMs() >= nextResubmitAt) {
|
|
397
|
+
// confirm() was false microseconds ago, so the composer still holds our paste — or the record
|
|
398
|
+
// landed in that gap and this CR meets an empty composer, which ignores it.
|
|
399
|
+
await resubmit(target.address)
|
|
400
|
+
nextResubmitAt = monotonicMs() + RESUBMIT_INTERVAL_MS
|
|
401
|
+
}
|
|
365
402
|
await sleep(LIVENESS_POLL_MS)
|
|
366
403
|
}
|
|
367
404
|
return err(
|