@dmsdc-ai/aigentry-telepty 0.6.4 → 0.6.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/CHANGELOG.md +28 -0
- package/daemon.js +179 -1
- package/package.json +4 -4
- package/src/submit-gate.js +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.6.5] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### Fixed — orchestrator REPORT loss: hold-and-redeliver queued injects until idle (#617)
|
|
8
|
+
|
|
9
|
+
- **A REPORT injected into a busy orchestrator TUI is no longer lost.** When `inject --submit`
|
|
10
|
+
is classified `queued` (consumed=false, recipient busy), the daemon now watches the
|
|
11
|
+
recipient's auto-state and **re-fires the CR when it transitions to idle** — bounded
|
|
12
|
+
(`MAX_REDELIVER=3` + total-time deadline; never an unbounded loop), and never-double-deliver
|
|
13
|
+
(re-fires only while the body is still parked, gated by the #615 consumption check before
|
|
14
|
+
AND after idle). Detached fire-and-forget; kill-switch `TELEPTY_REDELIVER=off`. Closes the
|
|
15
|
+
"`Submitted via pty_cr` succeeds but the busy recipient never starts a new turn" gap that
|
|
16
|
+
forced manual pull-fallback in every orchestration wave.
|
|
17
|
+
|
|
18
|
+
### Fixed — TASK_IDLE_UNCONFIRMED cry-wolf on long-running Claude turns (#619, telepty#54)
|
|
19
|
+
|
|
20
|
+
- **A genuinely-completed long Claude TUI turn no longer reports `TASK_IDLE_UNCONFIRMED`.**
|
|
21
|
+
Consumption is an EARLY event (the turn fires ~T+2s after inject) but the #52/#545 idle-gate
|
|
22
|
+
evaluated it LATE (at idle, often 13–23 min later) by re-deriving from the output-ring /
|
|
23
|
+
OSC133 marks — by then a long turn's injected body has scrolled off and no fresh REPL-done
|
|
24
|
+
mark remains, so the gate fell back to UNCONFIRMED on every long completion (cry-wolf, which
|
|
25
|
+
trains the orchestrator to ignore the signal and defeats #52's own safety purpose). The
|
|
26
|
+
daemon now **persists the consumption fact at consumption-time** (`maybeRecordInjectConsumption()`
|
|
27
|
+
records `injectConsumedAt` on the first genuine non-busy→working/thinking turn after the CR,
|
|
28
|
+
reusing the #615 consumed signal) and the idle-gate reads that **decay-proof stored fact**
|
|
29
|
+
instead of re-deriving from a stale screen. **#52 guarantee preserved — never a false
|
|
30
|
+
COMPLETE**: the fact is only recorded for a real turn AFTER the CR (startup / sub-state flips
|
|
31
|
+
and busy-park excluded), so a never-consumed inject still yields UNCONFIRMED.
|
|
32
|
+
|
|
5
33
|
## [0.6.4] - 2026-06-13
|
|
6
34
|
|
|
7
35
|
### Added — inject consumption-evidence: consumed | queued | unknown (#53)
|
package/daemon.js
CHANGED
|
@@ -43,6 +43,16 @@ const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOT
|
|
|
43
43
|
// 2026-05-30 surface-ownership verdict — telepty no longer foregrounds surfaces.
|
|
44
44
|
const WRAPPED_SUBMIT_DELAY_MS = 500;
|
|
45
45
|
|
|
46
|
+
// #617 hold-and-redeliver — when an inject(--submit) is classified `queued` (busy
|
|
47
|
+
// recipient parked the CR'd body in its composer, no turn fired), the daemon holds the
|
|
48
|
+
// parked body and re-fires the CR on the recipient's next busy→idle transition so the
|
|
49
|
+
// dropped REPORT turn finally starts. Bounded + never-double-deliver. Kill-switch
|
|
50
|
+
// TELEPTY_REDELIVER=off restores the pre-0.6.5 detect-only behavior (back-compat).
|
|
51
|
+
const REDELIVER_ENABLED = String(process.env.TELEPTY_REDELIVER || '').toLowerCase() !== 'off';
|
|
52
|
+
const REDELIVER_MAX_ATTEMPTS = Math.max(1, Number(process.env.TELEPTY_REDELIVER_MAX_ATTEMPTS || 3));
|
|
53
|
+
const REDELIVER_TOTAL_TIMEOUT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_TOTAL_TIMEOUT_MS || 600000));
|
|
54
|
+
const REDELIVER_IDLE_WAIT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_IDLE_WAIT_MS || 120000));
|
|
55
|
+
|
|
46
56
|
// Session state machine manager — auto-detects session state from PTY output
|
|
47
57
|
const sessionStateManager = new SessionStateManager({
|
|
48
58
|
idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
|
|
@@ -82,6 +92,11 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
|
|
|
82
92
|
pendingReport.sawWorkingAfterInject = true;
|
|
83
93
|
pendingReport.workingAfterInjectAt = new Date().toISOString();
|
|
84
94
|
}
|
|
95
|
+
// #619: capture the durable early-consumption fact the instant a genuine fresh turn
|
|
96
|
+
// fires, so the idle-gate (evaluated minutes later on a scrolled-off ring) reads the
|
|
97
|
+
// stored fact instead of failing to re-derive it. since_ms is set at this transition.
|
|
98
|
+
const consumedSinceMs = sessionStateManager.getState(sessionId)?.since_ms;
|
|
99
|
+
maybeRecordInjectConsumption(pendingReport, from, to, consumedSinceMs);
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
// Fire TASK_IDLE_NO_REPORT on idle transition (for sessions with pendingReports).
|
|
@@ -329,6 +344,35 @@ function pendingReportHasSubmitEvidence(pendingReport) {
|
|
|
329
344
|
));
|
|
330
345
|
}
|
|
331
346
|
|
|
347
|
+
// #619: persist inject-CONSUMPTION as a DURABLE FACT at consumption-time. The #52/#545 idle-
|
|
348
|
+
// gate re-derives consumption from the outputRing/OSC133 marks AT IDLE-TIME; on a long Claude
|
|
349
|
+
// turn (idle at T+13-23min) the injected body has scrolled off the ring, so the gate fails to
|
|
350
|
+
// re-derive a genuine completion → false TASK_IDLE_UNCONFIRMED. Recording the fact the instant
|
|
351
|
+
// the turn fires makes the idle-gate decay-proof (it reads the stored fact instead).
|
|
352
|
+
//
|
|
353
|
+
// never-false-complete (the #52 invariant) is preserved by recording ONLY the #615 `consumed`
|
|
354
|
+
// signal — a genuine FRESH turn that started at/after the inject CR:
|
|
355
|
+
// - the transition must enter a turn (→ working/thinking) FROM a non-busy state (idle/waiting);
|
|
356
|
+
// a `starting`→working startup flip (#537 pollution) and a working↔thinking mid-turn sub-
|
|
357
|
+
// state flip (an already-running turn, NOT ours) are both excluded;
|
|
358
|
+
// - the turn's since_ms must be ≥ the inject's submit-start (a turn that predates our CR is the
|
|
359
|
+
// #617 busy-park case — never our consumption);
|
|
360
|
+
// - a submit must have been attempted (submitStartedAt) — a non-submit text-inject records nothing.
|
|
361
|
+
// A never-consumed inject therefore never gets a fact and still signals UNCONFIRMED. Pure +
|
|
362
|
+
// idempotent (first genuine turn wins); mutates the passed pendingReport, returns whether it recorded.
|
|
363
|
+
function maybeRecordInjectConsumption(pendingReport, fromState, toState, transitionSinceMs) {
|
|
364
|
+
if (!pendingReport || pendingReport.injectConsumedAt) return false;
|
|
365
|
+
if (toState !== 'working' && toState !== 'thinking') return false;
|
|
366
|
+
if (fromState !== 'idle' && fromState !== 'waiting') return false;
|
|
367
|
+
if (!pendingReport.submitStartedAt) return false;
|
|
368
|
+
const submitStartedMs = new Date(pendingReport.submitStartedAt).getTime();
|
|
369
|
+
if (!Number.isFinite(submitStartedMs)) return false;
|
|
370
|
+
if (!Number.isFinite(transitionSinceMs) || transitionSinceMs < submitStartedMs) return false;
|
|
371
|
+
pendingReport.injectConsumedAt = new Date(transitionSinceMs).toISOString();
|
|
372
|
+
pendingReport.injectConsumedSinceMs = transitionSinceMs;
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
332
376
|
// #52: the TASK_IDLE_UNCONFIRMED semantic is "inject may NOT have been processed" — gate it
|
|
333
377
|
// on CONSUMPTION evidence the daemon already owns instead of screen idleness. Evidence:
|
|
334
378
|
// - a screen-VERIFIED submit confirmation (body consumed from the composer, or a state
|
|
@@ -339,6 +383,12 @@ function pendingReportHasSubmitEvidence(pendingReport) {
|
|
|
339
383
|
// no-land) is positive NON-consumption and can never be overridden by echo, so the
|
|
340
384
|
// never-false-complete invariant of #48 holds: a genuinely unconsumed inject still signals.
|
|
341
385
|
function observeConsumptionEvidence(pendingReport, session) {
|
|
386
|
+
// #619: a durable early-consumption fact (recorded at turn-start) is decay-proof — prefer it
|
|
387
|
+
// over re-deriving from the possibly scrolled-off outputRing at idle-time. This also covers the
|
|
388
|
+
// #48 settle re-entry path (where `confirmed` is force-false) as a suppression backstop.
|
|
389
|
+
if (pendingReport.injectConsumedAt) {
|
|
390
|
+
return { observed: true, reason: 'consumed_recorded' };
|
|
391
|
+
}
|
|
342
392
|
const confirm = pendingReport.submitConfirm;
|
|
343
393
|
if (confirm && confirm.accepted === false) {
|
|
344
394
|
return { observed: false, reason: 'submit_failed' };
|
|
@@ -463,6 +513,11 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
463
513
|
// as proof of processing — require positive submit confirmation (screen-poll verify /
|
|
464
514
|
// honest force / gate-off). Paths with no submit expected keep the legacy floor/work rule.
|
|
465
515
|
const strongSubmitConfirmed = !!(
|
|
516
|
+
// #619: a durable early-consumption fact (a genuine fresh turn fired by the inject) is
|
|
517
|
+
// the strongest completion proof there is — stronger than a screen-derived submit confirm
|
|
518
|
+
// and decay-proof at idle-time. Recorded conservatively (maybeRecordInjectConsumption), so
|
|
519
|
+
// this never promotes a never-consumed inject.
|
|
520
|
+
pendingReport.injectConsumedAt ||
|
|
466
521
|
pendingReport.submitConfirmedAt ||
|
|
467
522
|
(pendingReport.submitConfirm && pendingReport.submitConfirm.accepted === true)
|
|
468
523
|
);
|
|
@@ -473,9 +528,14 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
473
528
|
// symptom — a submit-confirmed worker still thinking), consistent with the BUG-B confirm gate;
|
|
474
529
|
// plain non-submit injects keep their existing floor-based completion. Absent flag / other
|
|
475
530
|
// triggers preserve prior behavior.
|
|
531
|
+
// #619: a recorded early-consumption fact overrides the decayed at-idle evidence. The
|
|
532
|
+
// `idleEvidenceReliable === false` downgrade exists because the screen-derived evidence is
|
|
533
|
+
// weak; a stored consumption fact IS the (decay-proof) evidence, so the downgrade no longer
|
|
534
|
+
// applies. Without a recorded fact, behavior is unchanged (#545/#52 conservative UNCONFIRMED).
|
|
476
535
|
const idleEvidenceUnreliable = trigger === 'real-idle'
|
|
477
536
|
&& pendingReport.submitExpected
|
|
478
|
-
&& deps.idleEvidenceReliable === false
|
|
537
|
+
&& deps.idleEvidenceReliable === false
|
|
538
|
+
&& !pendingReport.injectConsumedAt;
|
|
479
539
|
// #48: a settled recheck re-enters ONLY to emit the UNCONFIRMED label — pinned at arm time,
|
|
480
540
|
// so elapsed growing past the floor during the settle window can never promote a stale idle
|
|
481
541
|
// snapshot to TASK_COMPLETE (never a false complete).
|
|
@@ -984,6 +1044,97 @@ async function gatedTerminalSubmit(id, session, injectedBody, settleEnabled) {
|
|
|
984
1044
|
return terminalLevelSubmit(id, session);
|
|
985
1045
|
}
|
|
986
1046
|
|
|
1047
|
+
// #617 — detached hold-and-redeliver for a `queued` inject. The /submit response has
|
|
1048
|
+
// already returned (the worker's `telepty inject` got consumption='queued' / a plain
|
|
1049
|
+
// force success and EXITS), so the daemon owns delivery from here: it re-classifies
|
|
1050
|
+
// when the status is unknown (the force path skips the synchronous classify), and if
|
|
1051
|
+
// `queued`, runs the bounded submitGate.holdAndRedeliver loop — watching the recipient's
|
|
1052
|
+
// auto-state for busy→idle (awaitReplReady) and re-firing the bare CR (the body is still
|
|
1053
|
+
// parked in the composer) until it is consumed as a fresh turn. Fire-and-forget: never
|
|
1054
|
+
// awaited by the handler, so it cannot affect the response or block the caller.
|
|
1055
|
+
function scheduleQueuedRedeliver(id, session, injectedBody, opts = {}) {
|
|
1056
|
+
if (!REDELIVER_ENABLED) return;
|
|
1057
|
+
if (!injectedBody || injectedBody.length === 0) return;
|
|
1058
|
+
if (!session) return;
|
|
1059
|
+
// One in-flight redeliver per session — never stack idle-watchers for the same composer.
|
|
1060
|
+
if (session._redeliverInFlight) return;
|
|
1061
|
+
session._redeliverInFlight = true;
|
|
1062
|
+
|
|
1063
|
+
const emitSubmitBus = typeof opts.emitSubmitBus === 'function' ? opts.emitSubmitBus : () => {};
|
|
1064
|
+
const knownConsumption = opts.knownConsumption || null;
|
|
1065
|
+
|
|
1066
|
+
const isParked = () =>
|
|
1067
|
+
submitGate.observeBodyVisibility(session, injectedBody, { stripAnsi: stripAnsiState }).visible === true;
|
|
1068
|
+
|
|
1069
|
+
// Re-fire a bare CR (body already parked) then re-classify against a FRESH watermark:
|
|
1070
|
+
// the recipient is now idle, so a genuine idle→working/thinking turn is observable as
|
|
1071
|
+
// `consumed` (#53). A still-`queued` result means the CR did not fire the composer — retry.
|
|
1072
|
+
const fireCR = async () => {
|
|
1073
|
+
const strategy = await gatedTerminalSubmit(id, session, injectedBody, true);
|
|
1074
|
+
if (!strategy) return { redelivered: false, reason: 'strategy_failed' };
|
|
1075
|
+
const submittedAtMs = Date.now();
|
|
1076
|
+
const sinceBytes = session.outputRingTotalBytes || 0;
|
|
1077
|
+
const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
|
|
1078
|
+
submittedAtMs,
|
|
1079
|
+
sinceBytes,
|
|
1080
|
+
getState: () => sessionStateManager.getState(id),
|
|
1081
|
+
stripAnsi: stripAnsiState,
|
|
1082
|
+
});
|
|
1083
|
+
return { redelivered: c.status === 'consumed', reason: c.reason };
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const waitForIdle = (remainingMs) =>
|
|
1087
|
+
submitGate.awaitReplReady(id, sessionStateManager, {
|
|
1088
|
+
timeoutMs: Math.min(remainingMs, REDELIVER_IDLE_WAIT_MS),
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
const run = async () => {
|
|
1092
|
+
// Force path skips the synchronous classify — establish the `queued` precondition here
|
|
1093
|
+
// before holding an idle-watcher. Only a busy-parked body needs rescue.
|
|
1094
|
+
if (knownConsumption !== 'queued') {
|
|
1095
|
+
if (Number.isFinite(opts.submittedAtMs)) {
|
|
1096
|
+
const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
|
|
1097
|
+
submittedAtMs: opts.submittedAtMs,
|
|
1098
|
+
sinceBytes: Number.isFinite(opts.ringBytesAtSubmit) ? opts.ringBytesAtSubmit : (session.outputRingTotalBytes || 0),
|
|
1099
|
+
getState: () => sessionStateManager.getState(id),
|
|
1100
|
+
stripAnsi: stripAnsiState,
|
|
1101
|
+
});
|
|
1102
|
+
if (c.status !== 'queued') return; // consumed / unknown — nothing to redeliver
|
|
1103
|
+
} else {
|
|
1104
|
+
return; // no watermark to classify against — cannot safely redeliver
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
console.log(`[REDELIVER] ${id} inject queued on busy recipient — holding for idle to re-fire`);
|
|
1109
|
+
const result = await submitGate.holdAndRedeliver({
|
|
1110
|
+
waitForIdle,
|
|
1111
|
+
isStillParked: isParked,
|
|
1112
|
+
fireCR,
|
|
1113
|
+
maxAttempts: REDELIVER_MAX_ATTEMPTS,
|
|
1114
|
+
totalTimeoutMs: REDELIVER_TOTAL_TIMEOUT_MS,
|
|
1115
|
+
onAttempt: ({ attempt }) =>
|
|
1116
|
+
console.log(`[REDELIVER] ${id} idle — re-firing queued inject (attempt ${attempt}/${REDELIVER_MAX_ATTEMPTS})`),
|
|
1117
|
+
onExhausted: ({ reason, attempts }) => {
|
|
1118
|
+
console.log(`[REDELIVER] ${id} redeliver-exhausted (${reason}, attempts=${attempts})`);
|
|
1119
|
+
emitSubmitBus({ redeliver: 'exhausted', redeliver_reason: reason, redeliver_attempts: attempts });
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (result.status === 'redelivered') {
|
|
1124
|
+
console.log(`[REDELIVER] ${id} queued inject redelivered after ${result.attempts} attempt(s)`);
|
|
1125
|
+
markPendingReportSubmitConfirmed(id, { reason: 'redelivered', attempts: result.attempts });
|
|
1126
|
+
emitSubmitBus({ redeliver: 'redelivered', redeliver_attempts: result.attempts });
|
|
1127
|
+
} else if (result.status === 'already_consumed') {
|
|
1128
|
+
console.log(`[REDELIVER] ${id} queued inject already consumed (${result.reason}) — no re-fire`);
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
Promise.resolve()
|
|
1133
|
+
.then(run)
|
|
1134
|
+
.catch((err) => console.log(`[REDELIVER] ${id} redeliver error: ${err && err.message}`))
|
|
1135
|
+
.finally(() => { session._redeliverInFlight = false; });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
987
1138
|
async function executeBootstrapSubmit(sessionId, session, op) {
|
|
988
1139
|
const body = op.body || {};
|
|
989
1140
|
const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
|
|
@@ -2689,7 +2840,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2689
2840
|
if (injectedBody) {
|
|
2690
2841
|
markPendingReportSubmitStarted(id, injectedBody);
|
|
2691
2842
|
}
|
|
2843
|
+
const forceRingBytesAtSubmit = session.outputRingTotalBytes || 0;
|
|
2692
2844
|
const strategy = terminalLevelSubmit(id, session);
|
|
2845
|
+
const forceSubmittedAtMs = Date.now();
|
|
2693
2846
|
if (strategy) {
|
|
2694
2847
|
// #537 / Bug B: force-confirm must reflect ACTUAL delivery. A pty_cr fallback on a
|
|
2695
2848
|
// cmux surface means cmux send-key failed and Enter never reached the CLI — record
|
|
@@ -2702,6 +2855,17 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2702
2855
|
markPendingReportSubmitUnconfirmed(id, { reason: 'cmux_send_failed', attempts: 1, retryable: true });
|
|
2703
2856
|
}
|
|
2704
2857
|
}
|
|
2858
|
+
// #617: the force path skips the synchronous consumption classify, so a busy-parked
|
|
2859
|
+
// body would silently drop (this IS the worker's `--submit-force` REPORT path). Hand it
|
|
2860
|
+
// to the detached redeliver — it classifies against the CR watermark and only re-fires
|
|
2861
|
+
// if `queued`. No-op when the body was consumed/unknown or absent.
|
|
2862
|
+
if (injectedBody && deliveredToSurface) {
|
|
2863
|
+
scheduleQueuedRedeliver(id, session, injectedBody, {
|
|
2864
|
+
submittedAtMs: forceSubmittedAtMs,
|
|
2865
|
+
ringBytesAtSubmit: forceRingBytesAtSubmit,
|
|
2866
|
+
emitSubmitBus,
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2705
2869
|
emitSubmitBus({ strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
|
|
2706
2870
|
return res.json({ success: true, strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
|
|
2707
2871
|
}
|
|
@@ -2874,6 +3038,19 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2874
3038
|
verify.consumption_reason = consumptionResult.reason;
|
|
2875
3039
|
}
|
|
2876
3040
|
|
|
3041
|
+
// #617: a `queued` body was parked on a busy recipient and will never fire on its own.
|
|
3042
|
+
// Hand it to the detached hold-and-redeliver loop (re-fires the CR on busy→idle). This
|
|
3043
|
+
// runs independent of whether the handler returns 200 or 504 below — delivery is the
|
|
3044
|
+
// daemon's responsibility now that the worker no longer needs to poll the status.
|
|
3045
|
+
if (consumption === 'queued') {
|
|
3046
|
+
scheduleQueuedRedeliver(id, session, injectedBody, {
|
|
3047
|
+
knownConsumption: 'queued',
|
|
3048
|
+
submittedAtMs,
|
|
3049
|
+
ringBytesAtSubmit,
|
|
3050
|
+
emitSubmitBus,
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
|
|
2877
3054
|
if (confirm && !confirm.accepted) {
|
|
2878
3055
|
const reason = gatedDispatchAfterTimeout ? 'gated_dispatch_unconsumed' : 'submit_unconfirmed';
|
|
2879
3056
|
const failBody = {
|
|
@@ -4253,6 +4430,7 @@ if (require.main === module) {
|
|
|
4253
4430
|
// production call sites is unchanged. NOT a public API — internal/test use only.
|
|
4254
4431
|
module.exports = {
|
|
4255
4432
|
fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
|
|
4433
|
+
maybeRecordInjectConsumption, // #619: durable early-consumption fact capture (idle-gate decay-proofing)
|
|
4256
4434
|
forceSubmitDeliveredToSurface, // #544/#537/Bug B: PTY-native force-confirm (pty_cr = delivered)
|
|
4257
4435
|
terminalLevelSubmit, // #544: PTY-only submit path (pty_cr | null)
|
|
4258
4436
|
submitViaPty, // #544: bare-0x0D submit into the innermost node-pty
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"postinstall": "node scripts/postinstall.js",
|
|
39
39
|
"preuninstall": "node scripts/preuninstall.js",
|
|
40
|
-
"test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
-
"test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
|
|
42
|
-
"test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
40
|
+
"test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
+
"test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
|
|
42
|
+
"test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
45
45
|
},
|
package/src/submit-gate.js
CHANGED
|
@@ -517,6 +517,92 @@ async function classifyInjectConsumption(session, bodyText, opts = {}) {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
// #617 hold-and-redeliver — close the gap between #53 DETECTION and ACTION.
|
|
521
|
+
//
|
|
522
|
+
// A recipient that is BUSY when the inject CR is written parks the body in its
|
|
523
|
+
// composer ("Press up to edit queued messages") and never starts a turn (#53 fact 1).
|
|
524
|
+
// classifyInjectConsumption already DETECTS this (`queued`), but 0.6.4 only reports
|
|
525
|
+
// the status — the worker's `telepty inject` never reads it and exits, so the parked
|
|
526
|
+
// REPORT turn is silently dropped. This loop is the ACTION: hold the parked body,
|
|
527
|
+
// wait for the recipient's busy→idle transition, then re-fire the CR so the queued
|
|
528
|
+
// turn finally fires. The body is ALREADY in the composer, so a bare CR (fireCR)
|
|
529
|
+
// fires it — never re-inject the body (that would duplicate text).
|
|
530
|
+
//
|
|
531
|
+
// Invariants:
|
|
532
|
+
// - bounded: at most `maxAttempts` CR re-fires AND a `totalTimeoutMs` deadline —
|
|
533
|
+
// never an unbounded redeliver loop (Rule 27: redelivery is the fix, not a
|
|
534
|
+
// forever-retry workaround).
|
|
535
|
+
// - never-double-deliver: re-fire ONLY while the body is still parked
|
|
536
|
+
// (`isStillParked`, reusing #52/#53 echo observation at the daemon seam). The
|
|
537
|
+
// gate is checked before the idle wait AND again right after idle — a turn that
|
|
538
|
+
// auto-consumed the queue as it ended must not get a second CR.
|
|
539
|
+
// - back-compat: the daemon runs this DETACHED from the /submit response; a caller
|
|
540
|
+
// that ignores `consumption` is wholly unaffected.
|
|
541
|
+
//
|
|
542
|
+
// Pure: every effect (idle watch, parked check, CR fire) is injected; DI now. No
|
|
543
|
+
// daemon coupling — the daemon wires real implementations, tests wire doubles.
|
|
544
|
+
//
|
|
545
|
+
// @param {{
|
|
546
|
+
// waitForIdle: (remainingMs: number) => Promise<{ ready: boolean, reason?: string }>,
|
|
547
|
+
// isStillParked: () => boolean,
|
|
548
|
+
// fireCR: () => Promise<{ redelivered: boolean, reason?: string }>,
|
|
549
|
+
// maxAttempts?: number, totalTimeoutMs?: number,
|
|
550
|
+
// onAttempt?: Function, onExhausted?: Function, now?: Function
|
|
551
|
+
// }} opts
|
|
552
|
+
// @returns {Promise<{ status: 'redelivered'|'already_consumed'|'exhausted', reason: string, attempts: number, waited_ms: number }>}
|
|
553
|
+
async function holdAndRedeliver(opts = {}) {
|
|
554
|
+
const maxAttempts = Number.isFinite(opts.maxAttempts) ? opts.maxAttempts : 3;
|
|
555
|
+
const totalTimeoutMs = Number.isFinite(opts.totalTimeoutMs) ? opts.totalTimeoutMs : 600000;
|
|
556
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
557
|
+
const waitForIdle = typeof opts.waitForIdle === 'function' ? opts.waitForIdle : null;
|
|
558
|
+
const isStillParked = typeof opts.isStillParked === 'function' ? opts.isStillParked : () => true;
|
|
559
|
+
const fireCR = typeof opts.fireCR === 'function' ? opts.fireCR : null;
|
|
560
|
+
const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {};
|
|
561
|
+
const onExhausted = typeof opts.onExhausted === 'function' ? opts.onExhausted : () => {};
|
|
562
|
+
|
|
563
|
+
const start = now();
|
|
564
|
+
if (!waitForIdle || !fireCR) {
|
|
565
|
+
return { status: 'exhausted', reason: 'no_dependencies', attempts: 0, waited_ms: 0 };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let attempts = 0;
|
|
569
|
+
while (attempts < maxAttempts) {
|
|
570
|
+
if (now() - start >= totalTimeoutMs) {
|
|
571
|
+
onExhausted({ reason: 'deadline', attempts });
|
|
572
|
+
return { status: 'exhausted', reason: 'deadline', attempts, waited_ms: now() - start };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// never-double-deliver (pre-wait): if the body already left the composer, stop.
|
|
576
|
+
if (!isStillParked()) {
|
|
577
|
+
return { status: 'already_consumed', reason: 'not_parked', attempts, waited_ms: now() - start };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const remaining = totalTimeoutMs - (now() - start);
|
|
581
|
+
const idle = await waitForIdle(remaining);
|
|
582
|
+
if (!idle || !idle.ready) {
|
|
583
|
+
onExhausted({ reason: (idle && idle.reason) || 'idle_timeout', attempts });
|
|
584
|
+
return { status: 'exhausted', reason: 'idle_timeout', attempts, waited_ms: now() - start };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// never-double-deliver (post-idle): the turn that just ended may have auto-fired
|
|
588
|
+
// the queued body. Re-check before committing a CR.
|
|
589
|
+
if (!isStillParked()) {
|
|
590
|
+
return { status: 'already_consumed', reason: 'consumed_on_idle', attempts, waited_ms: now() - start };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
attempts++;
|
|
594
|
+
onAttempt({ attempt: attempts });
|
|
595
|
+
const fired = await fireCR();
|
|
596
|
+
if (fired && fired.redelivered) {
|
|
597
|
+
return { status: 'redelivered', reason: fired.reason || 'consumed', attempts, waited_ms: now() - start };
|
|
598
|
+
}
|
|
599
|
+
// still queued/unknown — loop to await the next idle window (bounded by maxAttempts).
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
onExhausted({ reason: 'max_attempts', attempts });
|
|
603
|
+
return { status: 'exhausted', reason: 'max_attempts', attempts, waited_ms: now() - start };
|
|
604
|
+
}
|
|
605
|
+
|
|
520
606
|
function isAcceptedSubmitState(state, submittedAtMs) {
|
|
521
607
|
if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
|
|
522
608
|
if (!Number.isFinite(submittedAtMs)) {
|
|
@@ -693,6 +779,7 @@ module.exports = {
|
|
|
693
779
|
observeBodyVisibility,
|
|
694
780
|
observeInjectEcho,
|
|
695
781
|
classifyInjectConsumption,
|
|
782
|
+
holdAndRedeliver,
|
|
696
783
|
awaitPromptSymbol,
|
|
697
784
|
defaultReadScreen,
|
|
698
785
|
isReady,
|