@agfpd/iapeer 0.2.74 → 0.2.75

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.74",
3
+ "version": "0.2.75",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,14 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
- import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
5
+ import { compactDoneBaseline, compactTranscriptHasDone, waitForCompactDone, type DeliveryTarget } from './index.ts'
6
+
7
+ function claudeTarget(): DeliveryTarget {
8
+ return { runtime: 'claude', personality: 'tester', address: 'claude-tester', socketPath: '/tmp/iapeer-test-noop.sock' }
9
+ }
10
+
11
+ const CLAUDE_IDLE_PANE = '❯ \n bypass permissions on'
12
+ const CLAUDE_BUSY_PANE = '· Compacting conversation… (esc to interrupt)\n reloading context'
6
13
 
7
14
  function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
8
15
  const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
@@ -58,3 +65,80 @@ describe('compact done transcript gate', () => {
58
65
  }
59
66
  }))
60
67
  })
68
+
69
+ describe('waitForCompactDone — marker is authoritative, idle is a fast-path', () => {
70
+ function withClaudeMarker(
71
+ fn: (args: { cwd: string; env: NodeJS.ProcessEnv; writeMarker: () => void; baseline: ReturnType<typeof compactDoneBaseline> }) => void,
72
+ ): void {
73
+ withHome((home, env) => {
74
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-wait-cwd-'))
75
+ try {
76
+ const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
77
+ mkdirSync(dir, { recursive: true })
78
+ const file = join(dir, 'session.jsonl')
79
+ writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }) + '\n')
80
+ const baseline = compactDoneBaseline('claude', cwd, { env })
81
+ const writeMarker = () =>
82
+ appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { durationMs: 140708, preTokens: 535706, postTokens: 12524 } }) + '\n')
83
+ fn({ cwd, env, writeMarker, baseline })
84
+ } finally {
85
+ rmSync(cwd, { recursive: true, force: true })
86
+ }
87
+ })
88
+ }
89
+
90
+ // THE FIX: a large-context session that auto-continues into a working turn after compact
91
+ // keeps the composer busy (never idle). The marker proves completion → SUCCESS, not failure.
92
+ test('marker seen + composer never idle → success on the marker (signal: transcript)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
93
+ writeMarker()
94
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
95
+ env,
96
+ timeoutMs: 5000,
97
+ pollMs: 5,
98
+ graceMs: 20,
99
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
100
+ })
101
+ expect(r.ok).toBe(true)
102
+ if (r.ok) expect(r.value.signal).toBe('transcript')
103
+ }))
104
+
105
+ // Clean common case unchanged: marker + idle composer → the historical 'transcript+ready'.
106
+ test('marker seen + composer idle → fast-path success (signal: transcript+ready)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
107
+ writeMarker()
108
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
109
+ env,
110
+ timeoutMs: 5000,
111
+ pollMs: 5,
112
+ graceMs: 5000,
113
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_IDLE_PANE },
114
+ })
115
+ expect(r.ok).toBe(true)
116
+ if (r.ok) expect(r.value.signal).toBe('transcript+ready')
117
+ }))
118
+
119
+ // BOUNDARY preserved: no structured marker ever → honest failure (hung / never completed).
120
+ test('no marker → honest failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
121
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
122
+ env,
123
+ timeoutMs: 40,
124
+ pollMs: 5,
125
+ graceMs: 20,
126
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
127
+ })
128
+ expect(r.ok).toBe(false)
129
+ if (!r.ok) expect(r.error.message).toContain('no transcript compact marker')
130
+ }))
131
+
132
+ // Session death during the wait still fails (liveness boundary).
133
+ test('session vanished → failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
134
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
135
+ env,
136
+ timeoutMs: 5000,
137
+ pollMs: 5,
138
+ graceMs: 20,
139
+ seam: { sessionAlive: () => false, capturePane: () => CLAUDE_BUSY_PANE },
140
+ })
141
+ expect(r.ok).toBe(false)
142
+ if (!r.ok) expect(r.error.message).toContain('session vanished')
143
+ }))
144
+ })
@@ -603,7 +603,23 @@ export interface CompactDoneBaseline {
603
603
  export interface CompactDoneWaitResult {
604
604
  ok: true
605
605
  ms: number
606
- signal: 'transcript+ready'
606
+ /** How completion was confirmed:
607
+ * 'transcript+ready' — marker seen AND the composer returned to idle (the clean,
608
+ * common case: a small/medium compact that settles back to the prompt).
609
+ * 'transcript' — marker seen but the composer is still BUSY past the ready-grace
610
+ * (a large-context session that auto-continues into a working turn right after
611
+ * compact). The structured marker is the AUTHORITATIVE completion signal, so this
612
+ * is SUCCESS, not a timeout. */
613
+ signal: 'transcript+ready' | 'transcript'
614
+ }
615
+
616
+ /** Test seam for waitForCompactDone — inject the tmux liveness + pane capture so the
617
+ * marker/grace logic is covered hermetically (no live session). Omitting all = prod. */
618
+ export interface CompactDoneWaitSeam {
619
+ /** Is the target session still alive? Default: the real tmux/host sessionAlive. */
620
+ sessionAlive?: (target: DeliveryTarget) => boolean
621
+ /** Capture the target pane; null = capture failed. Default: real tmux capture-pane. */
622
+ capturePane?: (target: DeliveryTarget) => string | null
607
623
  }
608
624
 
609
625
  function envHome(env: NodeJS.ProcessEnv | undefined): string {
@@ -763,41 +779,97 @@ function compactDonePollMs(env: NodeJS.ProcessEnv | undefined): number {
763
779
  return Number.isFinite(n) && n > 0 ? n : 1000
764
780
  }
765
781
 
782
+ // Once the completion marker is seen, how long to keep PREFERRING the idle-composer
783
+ // confirmation (the clean 'transcript+ready' signal) before reporting success on the
784
+ // marker alone. A large-context session that auto-continues into a working turn never
785
+ // goes idle — this bounds how long we wait for an idle that may never come, so the
786
+ // busy case succeeds promptly instead of stalling to the overall deadline. Short by
787
+ // design; the marker is already authoritative.
788
+ function compactReadyGraceMs(env: NodeJS.ProcessEnv | undefined): number {
789
+ const raw = env?.IAPEER_COMPACT_READY_GRACE_MS
790
+ const n = raw === undefined ? NaN : Number(raw)
791
+ return Number.isFinite(n) && n >= 0 ? n : 5000
792
+ }
793
+
766
794
  /**
767
795
  * Wait until a slash-/compact command has actually FINISHED, not merely until its
768
- * keystrokes were accepted. The primary signal is scroll-proof structured
769
- * transcript state written by the runtimes: Codex `context_compacted`, Claude
770
- * `compact_boundary`. The final input-ready check prevents a marker-only early
771
- * return while the TUI is still transitioning back to the composer/prompt.
796
+ * keystrokes were accepted. The AUTHORITATIVE signal is scroll-proof structured
797
+ * transcript state written by the runtimes at the END of the operation (with post-
798
+ * compact token metadata): Codex `context_compacted`, Claude `compact_boundary`.
799
+ * Once that marker is present the compact objectively completed.
800
+ *
801
+ * Idle-composer (`isInputReady`) is treated as a FAST-PATH confirmation, NOT a
802
+ * requirement. A healthy large-context session auto-continues into a working turn
803
+ * right after compact — its composer is legitimately busy (not idle) for a while, so
804
+ * requiring idle here false-failed a finished compact (live incident 15.06, boris:
805
+ * marker at 12:14:58, first post-compact idle only ~232s later, past the ~290s gate).
806
+ * Therefore: marker seen + composer idle → 'transcript+ready' (clean); marker seen +
807
+ * composer still busy past a short grace → 'transcript' (busy-but-done, still SUCCESS).
808
+ *
809
+ * BOUNDARY (preserved): a compact whose structured marker NEVER appears still fails
810
+ * honestly — that is the genuine "hung / never completed" case. Cross-runtime: the
811
+ * marker set + isInputReady are both per-adapter, so claude and codex share this path.
772
812
  */
773
813
  export function waitForCompactDone(
774
814
  target: DeliveryTarget,
775
815
  cwd: string,
776
816
  baseline: CompactDoneBaseline,
777
- opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number; pollMs?: number } = {},
817
+ opts: {
818
+ env?: NodeJS.ProcessEnv
819
+ timeoutMs?: number
820
+ pollMs?: number
821
+ graceMs?: number
822
+ seam?: CompactDoneWaitSeam
823
+ } = {},
778
824
  ): Result<CompactDoneWaitResult> {
779
825
  const env = opts.env ?? process.env
780
826
  const timeoutMs = opts.timeoutMs ?? compactDoneTimeoutMs(env)
781
827
  const pollMs = opts.pollMs ?? compactDonePollMs(env)
828
+ const graceMs = opts.graceMs ?? compactReadyGraceMs(env)
829
+ const aliveFn = opts.seam?.sessionAlive ?? ((t: DeliveryTarget) => sessionAlive(t.socketPath, t.address))
830
+ const captureFn =
831
+ opts.seam?.capturePane ??
832
+ ((t: DeliveryTarget) => {
833
+ const cap = tmux(t.socketPath, 'capture-pane', '-p', '-t', t.address)
834
+ return cap.ok ? cap.out : null
835
+ })
782
836
  const adapter = getAdapter(target.runtime)
783
837
  const started = monotonicMs()
784
838
  const deadline = started + timeoutMs
785
839
  let sawTranscriptDone = false
840
+ let markerSeenAtMs = -1
786
841
  let lastPane = ''
787
842
 
788
843
  do {
789
- if (!sessionAlive(target.socketPath, target.address)) {
844
+ if (!aliveFn(target)) {
790
845
  return err(`compact did not complete: tmux session vanished`)
791
846
  }
792
- sawTranscriptDone ||= compactTranscriptHasDone(baseline, { env })
793
- const pane = tmux(target.socketPath, 'capture-pane', '-p', '-t', target.address)
794
- if (pane.ok) lastPane = pane.out
795
- if (sawTranscriptDone && pane.ok && adapter.isInputReady(pane.out)) {
796
- return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
847
+ if (!sawTranscriptDone && compactTranscriptHasDone(baseline, { env })) {
848
+ sawTranscriptDone = true
849
+ markerSeenAtMs = monotonicMs()
850
+ }
851
+ const pane = captureFn(target)
852
+ if (pane !== null) lastPane = pane
853
+ if (sawTranscriptDone) {
854
+ // Fast clean path: the composer returned to idle (the historical signal).
855
+ if (pane !== null && adapter.isInputReady(pane)) {
856
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
857
+ }
858
+ // Marker is authoritative: a busy composer after a held marker is a healthy
859
+ // auto-continue, not a failure. Succeed on the marker once the grace elapses.
860
+ if (monotonicMs() - markerSeenAtMs >= graceMs) {
861
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
862
+ }
797
863
  }
798
864
  sleepSync(pollMs)
799
865
  } while (monotonicMs() < deadline)
800
866
 
867
+ // Deadline hit. The marker is authoritative: if it was EVER seen, the compact did
868
+ // finish — a still-busy composer at the deadline is not a failure (succeed on the
869
+ // marker). Only a MISSING marker is an honest failure (hung / never-completed compact).
870
+ if (sawTranscriptDone) {
871
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
872
+ }
801
873
  const tail = lastPane
802
874
  .split(/\r?\n/)
803
875
  .map(line => line.trim())
@@ -807,8 +879,7 @@ export function waitForCompactDone(
807
879
  .replace(/"/g, "'")
808
880
  .slice(0, 240)
809
881
  return err(
810
- `compact did not complete within ${timeoutMs}ms (` +
811
- `${sawTranscriptDone ? 'transcript marker seen, input not ready' : 'no transcript compact marker'}` +
882
+ `compact did not complete within ${timeoutMs}ms (no transcript compact marker` +
812
883
  `${tail ? `; pane tail: ${tail}` : ''})`,
813
884
  )
814
885
  }