Skip to content

Commit 035efd7

Browse files
ecgangclaude
andcommitted
fix(enable): advance import progress on skipped and dry-run turns
The progress counter only advanced on TurnWritten, so a fully idempotent re-import or a --dry-run pass sat at 'turn 0/M' and then rendered that stale count into the completion line. Add Progress.TurnSkipped, fired for every turn Run processes without writing (already imported, or DryRun), and drive the UI counter from both callbacks: exactly one of TurnWritten/TurnSkipped now fires per turn, so the counter always sweeps to M/M truthfully. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01KY95QCD5YDCST7YNYKW063FA
1 parent a169e1d commit 035efd7

4 files changed

Lines changed: 254 additions & 5 deletions

File tree

cmd/entire/cli/agentimport/agentimport.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ type Result struct {
131131
// (a progress bar, log lines, a TUI) is entirely up to the caller. Every
132132
// field is optional, and a nil *Progress (the default) is a no-op — Run's
133133
// behavior is byte-identical whether or not one is supplied.
134+
//
135+
// Invariant: for every turn Run processes, exactly one of TurnWritten or
136+
// TurnSkipped fires — so summing both callbacks' calls across one session
137+
// always equals that session's turnCount (as reported by SessionStart).
134138
type Progress struct {
135139
// SessionStart fires once per session, after its transcript has been
136140
// split into turns and before any of them are written. sessionIndex is
@@ -139,9 +143,14 @@ type Progress struct {
139143
SessionStart func(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int)
140144
// TurnWritten fires once per turn Run actually writes to the checkpoint
141145
// store — never for a turn skipped as already-imported, nor under
142-
// DryRun. turnIndex is 0-based against turnCount, matching the
143-
// turnCount reported by this turn's SessionStart call.
146+
// DryRun (see TurnSkipped for those). turnIndex is 0-based against
147+
// turnCount, matching the turnCount reported by this turn's
148+
// SessionStart call.
144149
TurnWritten func(sessionIndex, turnIndex, turnCount int)
150+
// TurnSkipped fires once per turn Run processes without writing: a turn
151+
// already imported (idempotent re-run) or, under DryRun, every turn
152+
// (dry runs never write). Index semantics match TurnWritten exactly.
153+
TurnSkipped func(sessionIndex, turnIndex, turnCount int)
145154
}
146155

147156
func (p *Progress) sessionStart(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int) {
@@ -158,6 +167,13 @@ func (p *Progress) turnWritten(sessionIndex, turnIndex, turnCount int) {
158167
p.TurnWritten(sessionIndex, turnIndex, turnCount)
159168
}
160169

170+
func (p *Progress) turnSkipped(sessionIndex, turnIndex, turnCount int) {
171+
if p == nil || p.TurnSkipped == nil {
172+
return
173+
}
174+
p.TurnSkipped(sessionIndex, turnIndex, turnCount)
175+
}
176+
161177
// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported
162178
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which
163179
// is how import stays idempotent.
@@ -214,10 +230,12 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
214230
cid := DeriveCheckpointID(sf.SessionID, turn.UUID)
215231
if existing[cid.String()] {
216232
res.TurnsSkipped++
233+
opts.Progress.turnSkipped(sessionIndex, turnIndex, len(turns))
217234
continue
218235
}
219236
if opts.DryRun {
220237
res.TurnsImported++ // counts what would import
238+
opts.Progress.turnSkipped(sessionIndex, turnIndex, len(turns))
221239
continue
222240
}
223241
if !redacted {

cmd/entire/cli/agentimport/progress_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,160 @@ func TestRun_NilProgressDoesNotPanic(t *testing.T) {
110110
t.Fatalf("want 4 imported, got %+v", resNil)
111111
}
112112
}
113+
114+
// progressRecorder collects Progress callback invocations for assertion.
115+
type progressRecorder struct {
116+
written []progressTurnEvent
117+
skipped []progressTurnEvent
118+
}
119+
120+
func (r *progressRecorder) progress() *Progress {
121+
return &Progress{
122+
TurnWritten: func(sessionIndex, turnIndex, turnCount int) {
123+
r.written = append(r.written, progressTurnEvent{sessionIndex, turnIndex, turnCount})
124+
},
125+
TurnSkipped: func(sessionIndex, turnIndex, turnCount int) {
126+
r.skipped = append(r.skipped, progressTurnEvent{sessionIndex, turnIndex, turnCount})
127+
},
128+
}
129+
}
130+
131+
// TestRun_ReimportFiresTurnSkippedNotTurnWritten proves a re-import over an
132+
// already-imported corpus (the idempotent-skip path) reports every turn via
133+
// TurnSkipped, in order, and never via TurnWritten — the P2 Codex's pre-push
134+
// review caught: without this, a TTY progress reporter driven only by
135+
// TurnWritten freezes at "turn 0/M" on a fully-skipped session.
136+
func TestRun_ReimportFiresTurnSkippedNotTurnWritten(t *testing.T) {
137+
t.Parallel()
138+
repo, repoDir := initRepoWithCommit(t)
139+
claudeDir := t.TempDir()
140+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
141+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
142+
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}
143+
144+
// First run: no progress, just to populate the store so the second run
145+
// hits the idempotent-skip path for every turn.
146+
if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil {
147+
t.Fatal(err)
148+
}
149+
150+
rec := &progressRecorder{}
151+
opts.Progress = rec.progress()
152+
res, err := Run(context.Background(), repo, claudeImporter{}, opts)
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
if res.TurnsImported != 0 || res.TurnsSkipped != 4 {
157+
t.Fatalf("want 0 imported / 4 skipped on re-import, got %+v", res)
158+
}
159+
160+
if len(rec.written) != 0 {
161+
t.Errorf("TurnWritten fired %d times on a fully-skipped re-import, want 0: %+v", len(rec.written), rec.written)
162+
}
163+
wantSkipped := []progressTurnEvent{
164+
{sessionIndex: 0, turnIndex: 0, turnCount: 2},
165+
{sessionIndex: 0, turnIndex: 1, turnCount: 2},
166+
{sessionIndex: 1, turnIndex: 0, turnCount: 2},
167+
{sessionIndex: 1, turnIndex: 1, turnCount: 2},
168+
}
169+
if !reflect.DeepEqual(rec.skipped, wantSkipped) {
170+
t.Fatalf("TurnSkipped events = %+v, want %+v", rec.skipped, wantSkipped)
171+
}
172+
}
173+
174+
// TestRun_DryRunFiresTurnSkippedForEveryTurn proves DryRun — which never
175+
// writes — reports every turn via TurnSkipped and never via TurnWritten.
176+
func TestRun_DryRunFiresTurnSkippedForEveryTurn(t *testing.T) {
177+
t.Parallel()
178+
repo, repoDir := initRepoWithCommit(t)
179+
claudeDir := t.TempDir()
180+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
181+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
182+
183+
rec := &progressRecorder{}
184+
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
185+
RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true,
186+
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
187+
Progress: rec.progress(),
188+
})
189+
if err != nil {
190+
t.Fatal(err)
191+
}
192+
if res.TurnsImported != 4 {
193+
// DryRun's Result bookkeeping is unchanged: TurnsImported still means
194+
// "would import" (see Run's DryRun branch). TurnSkipped is a separate,
195+
// additive signal that nothing was actually written.
196+
t.Fatalf("want 4 (would-import), got %+v", res)
197+
}
198+
199+
if len(rec.written) != 0 {
200+
t.Errorf("TurnWritten fired %d times under DryRun, want 0: %+v", len(rec.written), rec.written)
201+
}
202+
if len(rec.skipped) != 4 {
203+
t.Fatalf("TurnSkipped fired %d times under DryRun, want 4: %+v", len(rec.skipped), rec.skipped)
204+
}
205+
}
206+
207+
// TestRun_MixedSkipAndWriteSatisfiesInvariant proves the documented
208+
// invariant — for every turn, exactly one of TurnWritten/TurnSkipped fires,
209+
// so per-session written+skipped == turnCount — holds when a run mixes
210+
// already-imported sessions with a brand-new one in a single call.
211+
func TestRun_MixedSkipAndWriteSatisfiesInvariant(t *testing.T) {
212+
t.Parallel()
213+
repo, repoDir := initRepoWithCommit(t)
214+
claudeDir := t.TempDir()
215+
writeFixtureSession(t, claudeDir, "sess1.jsonl")
216+
writeFixtureSession(t, claudeDir, "sess2.jsonl")
217+
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}
218+
219+
// Import sess1 and sess2 first, so a second run finds them already
220+
// imported while a newly-added sess3 is still fresh.
221+
if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil {
222+
t.Fatal(err)
223+
}
224+
writeFixtureSession(t, claudeDir, "sess3.jsonl")
225+
226+
rec := &progressRecorder{}
227+
opts.Progress = rec.progress()
228+
res, err := Run(context.Background(), repo, claudeImporter{}, opts)
229+
if err != nil {
230+
t.Fatal(err)
231+
}
232+
if res.TurnsImported != 2 || res.TurnsSkipped != 4 {
233+
t.Fatalf("want 2 imported (sess3) / 4 skipped (sess1+sess2), got %+v", res)
234+
}
235+
236+
counts := map[int]struct{ written, skipped int }{}
237+
for _, ev := range rec.written {
238+
c := counts[ev.sessionIndex]
239+
c.written++
240+
counts[ev.sessionIndex] = c
241+
}
242+
for _, ev := range rec.skipped {
243+
c := counts[ev.sessionIndex]
244+
c.skipped++
245+
counts[ev.sessionIndex] = c
246+
}
247+
248+
// Discovery is sorted by path, so sess1=0, sess2=1, sess3=2 (each has 2
249+
// turns per writeFixtureSession).
250+
wantBySession := map[int]struct{ written, skipped int }{
251+
0: {written: 0, skipped: 2}, // sess1: already imported
252+
1: {written: 0, skipped: 2}, // sess2: already imported
253+
2: {written: 2, skipped: 0}, // sess3: brand new
254+
}
255+
if len(counts) != len(wantBySession) {
256+
t.Fatalf("saw events for %d sessions, want %d: %+v", len(counts), len(wantBySession), counts)
257+
}
258+
for sessionIndex, want := range wantBySession {
259+
got := counts[sessionIndex]
260+
if got != want {
261+
t.Errorf("session %d: written=%d skipped=%d, want written=%d skipped=%d",
262+
sessionIndex, got.written, got.skipped, want.written, want.skipped)
263+
}
264+
if got.written+got.skipped != 2 {
265+
t.Errorf("session %d: written+skipped = %d, want turnCount 2 (invariant violated)",
266+
sessionIndex, got.written+got.skipped)
267+
}
268+
}
269+
}

cmd/entire/cli/import_progress.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,21 @@ func newImportProgressReporter(w io.Writer, agentName string) (progress *agentim
3333
update(fmt.Sprintf("Importing %s sessions... (session %d/%d · turn %d/%d)",
3434
agentName, curSession, curSessionTotal, turnsDone, curTurnTotal))
3535
}
36+
advance := func(_, turnIndex, _ int) {
37+
render(turnIndex + 1)
38+
}
3639
progress = &agentimport.Progress{
3740
SessionStart: func(sessionIndex, sessionTotal int, _, _ string, turnCount int) {
3841
curSession, curSessionTotal, curTurnTotal = sessionIndex+1, sessionTotal, turnCount
3942
render(0)
4043
},
41-
TurnWritten: func(_, turnIndex, _ int) {
42-
render(turnIndex + 1)
43-
},
44+
// TurnWritten and TurnSkipped share the same advance path: the
45+
// counter must sweep to turnCount/turnCount regardless of *why* a
46+
// turn didn't need writing (already imported, or DryRun), otherwise
47+
// a fully-skipped or dry-run session's completion line would freeze
48+
// at "turn 0/M".
49+
TurnWritten: advance,
50+
TurnSkipped: advance,
4451
}
4552
return progress, spinnerStop
4653
}

cmd/entire/cli/setup_import_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,70 @@ func TestRunSelectedImports_NonTTYProgressLines(t *testing.T) {
375375
t.Errorf("final summary line missing or changed; want %q in:\n%s", want, out)
376376
}
377377
}
378+
379+
// TestRunSelectedImports_NonTTYProgressLines_Reimport proves a second,
380+
// idempotent pass over an already-imported corpus (every turn hits
381+
// agentimport's TurnSkipped path, not TurnWritten) still prints one plain
382+
// progress line per session and reports the correct "0 imported" summary —
383+
// the non-TTY side of the P2 Codex's pre-push review caught (the TTY side is
384+
// covered by agentimport's TestRun_ReimportFiresTurnSkippedNotTurnWritten
385+
// and this package's TestNewImportProgressReporter_TTYAdvancesOnSkip).
386+
func TestRunSelectedImports_NonTTYProgressLines_Reimport(t *testing.T) {
387+
// Not parallel: chdirs into a temp repo and performs real checkpoint writes.
388+
dir := t.TempDir()
389+
testutil.InitRepo(t, dir)
390+
testutil.WriteFile(t, dir, "f.txt", "x")
391+
testutil.GitAdd(t, dir, "f.txt")
392+
testutil.GitCommit(t, dir, "init")
393+
t.Chdir(dir)
394+
ctx := context.Background()
395+
396+
sessionsDir := t.TempDir()
397+
writeImportProgressFixtureSession(t, sessionsDir, "sess1.jsonl")
398+
writeImportProgressFixtureSession(t, sessionsDir, "sess2.jsonl")
399+
400+
var claudeImp agentimport.Importer
401+
for _, imp := range agentimport.All() {
402+
if imp.Name() == testAgentName {
403+
claudeImp = imp
404+
}
405+
}
406+
if claudeImp == nil {
407+
t.Fatal("claude-code importer not registered")
408+
}
409+
sessions, err := claudeImp.Discover(dir, sessionsDir, time.Now(), nil)
410+
if err != nil {
411+
t.Fatalf("discover fixture sessions: %v", err)
412+
}
413+
agentName := string(claudeImp.AgentType())
414+
selected := []eligibleImport{{
415+
imp: fixedDiscoverImporter{Importer: claudeImp, sessions: sessions},
416+
displayName: agentName,
417+
}}
418+
419+
// First pass actually imports; discard its output.
420+
runSelectedImports(ctx, io.Discard, dir, selected)
421+
422+
// Second pass: every turn is already imported, so agentimport.Run's loop
423+
// only ever calls TurnSkipped for it — this is the scenario that used to
424+
// leave a TTY reporter frozen at "turn 0/M".
425+
var buf bytes.Buffer
426+
runSelectedImports(ctx, &buf, dir, selected)
427+
out := buf.String()
428+
429+
if strings.ContainsRune(out, '\x1b') {
430+
t.Fatalf("re-import output contains an ESC byte on a non-TTY writer: %q", out)
431+
}
432+
wantLines := []string{
433+
fmt.Sprintf("Importing %s session 1/2 (2 turns)...", agentName),
434+
fmt.Sprintf("Importing %s session 2/2 (2 turns)...", agentName),
435+
}
436+
for _, line := range wantLines {
437+
if !strings.Contains(out, line) {
438+
t.Errorf("missing progress line %q in re-import output:\n%s", line, out)
439+
}
440+
}
441+
if want := "Imported 0 turn(s) from 2 session(s) (4 already imported).\n"; !strings.Contains(out, want) {
442+
t.Errorf("re-import summary line missing or wrong; want %q in:\n%s", want, out)
443+
}
444+
}

0 commit comments

Comments
 (0)