From a3051055f46efad10c923ca4c485b48a709c9e76 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Thu, 19 Mar 2026 21:41:08 +0100 Subject: [PATCH 1/2] Scale E2E checkpoint wait timeouts by agent TimeoutMultiplier The hardcoded 15s checkpoint polling timeout was too short for slower agents like Copilot CLI. Add RepoState.CheckpointTimeout() that scales the base 15s by each agent's TimeoutMultiplier, and bump Copilot CLI from 1.5x to 2.5x. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: e6d21f633a5a --- e2e/agents/copilot-cli.go | 2 +- e2e/tests/attribution_test.go | 12 ++++++------ e2e/tests/checkpoint_metadata_test.go | 2 +- e2e/tests/deleted_files_test.go | 2 +- e2e/tests/edge_cases_test.go | 12 ++++++------ e2e/tests/existing_files_test.go | 10 +++++----- e2e/tests/external_agent_test.go | 8 ++++---- e2e/tests/interactive_test.go | 2 +- e2e/tests/mid_turn_commit_test.go | 2 +- e2e/tests/multi_session_test.go | 4 ++-- e2e/tests/resume_remote_test.go | 4 ++-- e2e/tests/resume_test.go | 8 ++++---- e2e/tests/rewind_test.go | 6 +++--- e2e/tests/session_lifecycle_test.go | 6 +++--- e2e/tests/single_session_test.go | 6 +++--- e2e/tests/split_commits_test.go | 14 +++++++------- e2e/tests/stash_workflows_test.go | 12 ++++++------ e2e/tests/subagent_commit_flow_test.go | 2 +- e2e/testutil/repo.go | 6 ++++++ 19 files changed, 63 insertions(+), 57 deletions(-) diff --git a/e2e/agents/copilot-cli.go b/e2e/agents/copilot-cli.go index 30e2a7f0b..27b79ebf5 100644 --- a/e2e/agents/copilot-cli.go +++ b/e2e/agents/copilot-cli.go @@ -25,7 +25,7 @@ func (c *CopilotCLI) Name() string { return "copilot-cli" } func (c *CopilotCLI) Binary() string { return "copilot" } func (c *CopilotCLI) EntireAgent() string { return "copilot-cli" } func (c *CopilotCLI) PromptPattern() string { return `❯` } -func (c *CopilotCLI) TimeoutMultiplier() float64 { return 1.5 } +func (c *CopilotCLI) TimeoutMultiplier() float64 { return 2.5 } func (c *CopilotCLI) IsTransientError(out Output, err error) bool { if errors.Is(err, context.DeadlineExceeded) { diff --git a/e2e/tests/attribution_test.go b/e2e/tests/attribution_test.go index 50804f467..07ce850bd 100644 --- a/e2e/tests/attribution_test.go +++ b/e2e/tests/attribution_test.go @@ -29,7 +29,7 @@ func TestLineAttributionReasonable(t *testing.T) { s.Git(t, "add", "docs/") s.Git(t, "commit", "-m", "Add example.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0) @@ -62,7 +62,7 @@ func TestInteractiveAttributionOnAgentCommit(t *testing.T) { s.WaitFor(t, session, prompt, 90*time.Second) testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0) @@ -93,7 +93,7 @@ func TestInteractiveAttributionMultiCommitSameSession(t *testing.T) { s.WaitFor(t, session, prompt, 60*time.Second) testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") // Second prompt: modify same file and commit again. @@ -101,7 +101,7 @@ func TestInteractiveAttributionMultiCommitSameSession(t *testing.T) { s.WaitFor(t, session, prompt, 90*time.Second) testutil.AssertNewCommits(t, s, 2) - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") sm := testutil.WaitForSessionMetadata(t, s.Dir, cpID2, 0, 10*time.Second) @@ -133,7 +133,7 @@ func TestInteractiveShadowBranchCleanedAfterAgentCommit(t *testing.T) { s.WaitFor(t, session, prompt, 90*time.Second) testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertNoShadowBranches(t, s.Dir) }) @@ -170,7 +170,7 @@ func TestAttributionMixedHumanAndAgent(t *testing.T) { s.Git(t, "add", "agent.txt", "human.txt") s.Git(t, "commit", "-m", "Add agent and human files") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0) diff --git a/e2e/tests/checkpoint_metadata_test.go b/e2e/tests/checkpoint_metadata_test.go index ad018f432..8574dd335 100644 --- a/e2e/tests/checkpoint_metadata_test.go +++ b/e2e/tests/checkpoint_metadata_test.go @@ -27,7 +27,7 @@ func TestCheckpointMetadataDeepValidation(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add validated.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") testutil.ValidateCheckpointDeep(t, s.Dir, testutil.DeepCheckpointValidation{ diff --git a/e2e/tests/deleted_files_test.go b/e2e/tests/deleted_files_test.go index ec21e3224..06a83597d 100644 --- a/e2e/tests/deleted_files_test.go +++ b/e2e/tests/deleted_files_test.go @@ -39,7 +39,7 @@ func TestDeletedFilesCommitDeletion(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add replacement") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") testutil.AssertCheckpointExists(t, s.Dir, cpID1) diff --git a/e2e/tests/edge_cases_test.go b/e2e/tests/edge_cases_test.go index 6fa041865..325e4ee78 100644 --- a/e2e/tests/edge_cases_test.go +++ b/e2e/tests/edge_cases_test.go @@ -26,7 +26,7 @@ func TestAgentContinuesAfterCommit(t *testing.T) { t.Fatalf("agent prompt 1 failed: %v", err) } - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranchAfterFirst := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -41,7 +41,7 @@ func TestAgentContinuesAfterCommit(t *testing.T) { s.Git(t, "commit", "-m", "Add blue.md") // Wait for checkpoint branch to advance past the first checkpoint. - deadline := time.Now().Add(15 * time.Second) + deadline := time.Now().Add(s.CheckpointTimeout()) for time.Now().Before(deadline) { after := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") if after != cpBranchAfterFirst { @@ -70,7 +70,7 @@ func TestAgentAmendsCommit(t *testing.T) { } testutil.AssertFileExists(t, s.Dir, "docs/red.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") // Agent creates another file and amends the previous commit. @@ -109,7 +109,7 @@ func TestDirtyWorkingTree(t *testing.T) { t.Fatalf("agent failed: %v", err) } - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertFileExists(t, s.Dir, "docs/red.md") // Human's uncommitted file should be untouched. @@ -176,13 +176,13 @@ func TestAgentCommitsMidTurnUserCommitsRemainder(t *testing.T) { testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpBranchAfterAgent := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") s.Git(t, "add", "user_remainder.go") s.Git(t, "commit", "-m", "Add user remainder") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterAgent, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterAgent, s.CheckpointTimeout()) userCpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") agentCpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD~1") diff --git a/e2e/tests/existing_files_test.go b/e2e/tests/existing_files_test.go index d0a4460fb..366ca7bd8 100644 --- a/e2e/tests/existing_files_test.go +++ b/e2e/tests/existing_files_test.go @@ -37,7 +37,7 @@ func TestModifyExistingTrackedFile(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Update config.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -72,7 +72,7 @@ func TestMixedNewAndModifiedFiles(t *testing.T) { s.Git(t, "add", "src/main.go") s.Git(t, "commit", "-m", "Update main.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -80,7 +80,7 @@ func TestMixedNewAndModifiedFiles(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add utils.go and types.go") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") @@ -114,7 +114,7 @@ func TestInteractiveContentOverlapRevertNewFile(t *testing.T) { // Wait for session to transition from ACTIVE to IDLE. The prompt // pattern may appear before the turn-end hook completes (race between // TUI rendering and hook execution, especially with OpenCode). - testutil.WaitForSessionIdle(t, s.Dir, 15*time.Second) + testutil.WaitForSessionIdle(t, s.Dir, s.CheckpointTimeout()) // Session is now idle (turn ended, waiting for next prompt). // User replaces the content entirely. @@ -167,7 +167,7 @@ func TestModifiedFileAlwaysGetsCheckpoint(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Rewrite config.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/tests/external_agent_test.go b/e2e/tests/external_agent_test.go index 02d667607..2d76a622c 100644 --- a/e2e/tests/external_agent_test.go +++ b/e2e/tests/external_agent_test.go @@ -29,7 +29,7 @@ func TestExternalAgentSingleSessionManualCommit(t *testing.T) { s.Git(t, "commit", "-m", "Add hello file via external agent") testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -69,7 +69,7 @@ func TestExternalAgentMultipleTurnsManualCommit(t *testing.T) { s.Git(t, "commit", "-m", "Add alpha and beta via external agent") testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -99,7 +99,7 @@ func TestExternalAgentDeepCheckpointValidation(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Deep checkpoint validation test") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -125,7 +125,7 @@ func TestExternalAgentSessionMetadata(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Metadata test commit") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/tests/interactive_test.go b/e2e/tests/interactive_test.go index 9f604ccdd..b8c9944aa 100644 --- a/e2e/tests/interactive_test.go +++ b/e2e/tests/interactive_test.go @@ -29,7 +29,7 @@ func TestInteractiveMultiStep(t *testing.T) { s.WaitFor(t, session, prompt, 60*time.Second) testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD") testutil.AssertNoShadowBranches(t, s.Dir) }) diff --git a/e2e/tests/mid_turn_commit_test.go b/e2e/tests/mid_turn_commit_test.go index 382a4a0a6..c1855e4b1 100644 --- a/e2e/tests/mid_turn_commit_test.go +++ b/e2e/tests/mid_turn_commit_test.go @@ -52,7 +52,7 @@ func TestMidTurnCommit_DifferentFilesThanPreviousTurn(t *testing.T) { // CRITICAL: The mid-turn commit should have triggered condensation // to entire/checkpoints/v1, even though committed files differ from // Turn 1's tracked files. This is the regression assertion. - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/tests/multi_session_test.go b/e2e/tests/multi_session_test.go index dfe3e7007..1c8634716 100644 --- a/e2e/tests/multi_session_test.go +++ b/e2e/tests/multi_session_test.go @@ -29,7 +29,7 @@ func TestMultiSessionManualCommit(t *testing.T) { testutil.AssertFileExists(t, s.Dir, "docs/*.md") testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -56,7 +56,7 @@ func TestMultiSessionSequential(t *testing.T) { testutil.AssertFileExists(t, s.Dir, "docs/*.md") testutil.AssertNewCommits(t, s, 2) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/tests/resume_remote_test.go b/e2e/tests/resume_remote_test.go index a587fab24..a05a27984 100644 --- a/e2e/tests/resume_remote_test.go +++ b/e2e/tests/resume_remote_test.go @@ -40,7 +40,7 @@ func TestResumeFromClonedRepo(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add hello doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) // Push feature branch and metadata branch to the bare remote. s.Git(t, "push", "-u", "origin", "feature") @@ -108,7 +108,7 @@ func TestResumeMetadataBranchAlreadyLocal(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add hello doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) // Switch back to main and resume the feature branch. // The metadata branch exists locally (was created during commit). diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go index 95e0e56d5..3d4c17034 100644 --- a/e2e/tests/resume_test.go +++ b/e2e/tests/resume_test.go @@ -40,7 +40,7 @@ func TestResumeFromFeatureBranch(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add hello doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) // Switch back to main and resume the feature branch. s.Git(t, "checkout", mainBranch) @@ -86,7 +86,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add red doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cp1Ref := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") _, err = s.RunPrompt(t, ctx, @@ -97,7 +97,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add blue doc") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, s.CheckpointTimeout()) // Record checkpoint IDs from both feature branch commits. cpID1 := testutil.GetCheckpointTrailer(t, s.Dir, "HEAD~1") @@ -214,7 +214,7 @@ func TestResumeOlderCheckpointWithNewerCommits(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add hello doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) // Add a human-only commit on top (no agent involvement, no checkpoint). require.NoError(t, os.MkdirAll(filepath.Join(s.Dir, "notes"), 0o755)) diff --git a/e2e/tests/rewind_test.go b/e2e/tests/rewind_test.go index 999158e93..92d877153 100644 --- a/e2e/tests/rewind_test.go +++ b/e2e/tests/rewind_test.go @@ -87,7 +87,7 @@ func TestRewindAfterCommit(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add red.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) // Get rewind points after commit — old shadow IDs should be gone. pointsAfter := entire.RewindList(t, s.Dir) @@ -173,7 +173,7 @@ func TestRewindSquashMergeMultipleCheckpoints(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add red doc") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cp1Ref := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") _, err = s.RunPrompt(t, ctx, @@ -184,7 +184,7 @@ func TestRewindSquashMergeMultipleCheckpoints(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add blue doc") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, s.CheckpointTimeout()) // Record checkpoint IDs from both feature branch commits. cpID1 := testutil.GetCheckpointTrailer(t, s.Dir, "HEAD~1") diff --git a/e2e/tests/session_lifecycle_test.go b/e2e/tests/session_lifecycle_test.go index f111fcb42..44913fe86 100644 --- a/e2e/tests/session_lifecycle_test.go +++ b/e2e/tests/session_lifecycle_test.go @@ -33,14 +33,14 @@ func TestEndedSessionUserCommitsAfterExit(t *testing.T) { s.Git(t, "add", "ended_a.go", "ended_b.go") s.Git(t, "commit", "-m", "Add ended files A and B") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranchAfterFirst := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") s.Git(t, "add", "ended_c.go") s.Git(t, "commit", "-m", "Add ended file C") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterFirst, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterFirst, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "each commit should have its own checkpoint ID") @@ -66,7 +66,7 @@ func TestSessionDepletedManualEditNoCheckpoint(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add depleted.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") testutil.AssertCheckpointExists(t, s.Dir, cpID) diff --git a/e2e/tests/single_session_test.go b/e2e/tests/single_session_test.go index fef974e0a..51b2827df 100644 --- a/e2e/tests/single_session_test.go +++ b/e2e/tests/single_session_test.go @@ -53,7 +53,7 @@ func TestSingleSessionManualCommit(t *testing.T) { testutil.AssertFileExists(t, s.Dir, "docs/*.md") testutil.AssertNewCommits(t, s, 1) - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -76,7 +76,7 @@ func TestSingleSessionAgentCommitInTurn(t *testing.T) { testutil.AssertFileExists(t, s.Dir, "docs/*.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") @@ -99,7 +99,7 @@ func TestSingleSessionSubagentCommitInTurn(t *testing.T) { testutil.AssertFileExists(t, s.Dir, "docs/*.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/tests/split_commits_test.go b/e2e/tests/split_commits_test.go index cb8c41459..55bdc0c31 100644 --- a/e2e/tests/split_commits_test.go +++ b/e2e/tests/split_commits_test.go @@ -31,7 +31,7 @@ func TestUserSplitsAgentChanges(t *testing.T) { s.Git(t, "add", "docs/a.md", "docs/b.md") s.Git(t, "commit", "-m", "Add a.md and b.md") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -39,7 +39,7 @@ func TestUserSplitsAgentChanges(t *testing.T) { s.Git(t, "add", "-A") s.Git(t, "commit", "-m", "Commit remaining changes (including c.md and d.md)") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") @@ -74,7 +74,7 @@ func TestPartialStaging(t *testing.T) { s.Git(t, "add", "src/main.go") s.Git(t, "commit", "-m", "Add hello world") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -88,7 +88,7 @@ func TestPartialStaging(t *testing.T) { s.Git(t, "add", "-A") s.Git(t, "commit", "-m", "Add goodbye world") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") @@ -126,7 +126,7 @@ func TestSplitModificationsToExistingFiles(t *testing.T) { s.Git(t, "add", "src/model.go") s.Git(t, "commit", "-m", "Update model.go") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -134,7 +134,7 @@ func TestSplitModificationsToExistingFiles(t *testing.T) { s.Git(t, "add", "src/view.go") s.Git(t, "commit", "-m", "Update view.go") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch2 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -142,7 +142,7 @@ func TestSplitModificationsToExistingFiles(t *testing.T) { s.Git(t, "add", "-A") s.Git(t, "commit", "-m", "Commit remaining changes") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch2, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch2, s.CheckpointTimeout()) cpID3 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") // All three checkpoints should be distinct and valid. diff --git a/e2e/tests/stash_workflows_test.go b/e2e/tests/stash_workflows_test.go index e7388b55f..f4ac0fac8 100644 --- a/e2e/tests/stash_workflows_test.go +++ b/e2e/tests/stash_workflows_test.go @@ -36,7 +36,7 @@ func TestPartialCommitStashNewPrompt(t *testing.T) { s.Git(t, "add", "docs/b.md", "docs/c.md") s.Git(t, "stash") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -53,7 +53,7 @@ func TestPartialCommitStashNewPrompt(t *testing.T) { s.Git(t, "add", "docs/d.md", "docs/e.md") s.Git(t, "commit", "-m", "Add d.md and e.md") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") @@ -85,7 +85,7 @@ func TestStashSecondPromptUnstashCommitAll(t *testing.T) { s.Git(t, "add", "docs/b.md", "docs/c.md") s.Git(t, "stash") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -103,7 +103,7 @@ func TestStashSecondPromptUnstashCommitAll(t *testing.T) { s.Git(t, "add", "docs/") s.Git(t, "commit", "-m", "Add b, c, d, e") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") @@ -148,7 +148,7 @@ func TestStashModificationsToTrackedFiles(t *testing.T) { // Stash b.go modifications. s.Git(t, "stash") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") @@ -157,7 +157,7 @@ func TestStashModificationsToTrackedFiles(t *testing.T) { s.Git(t, "add", "src/b.go") s.Git(t, "commit", "-m", "Update b.go") - testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second) + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout()) cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") diff --git a/e2e/tests/subagent_commit_flow_test.go b/e2e/tests/subagent_commit_flow_test.go index 07ad9e337..2936b33a2 100644 --- a/e2e/tests/subagent_commit_flow_test.go +++ b/e2e/tests/subagent_commit_flow_test.go @@ -26,7 +26,7 @@ func TestSubagentCommitFlow(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Add red.md via subagent") - testutil.WaitForCheckpoint(t, s, 15*time.Second) + testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout()) testutil.AssertCheckpointAdvanced(t, s) cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD") diff --git a/e2e/testutil/repo.go b/e2e/testutil/repo.go index e0e5e2bb2..fa52a5d29 100644 --- a/e2e/testutil/repo.go +++ b/e2e/testutil/repo.go @@ -412,6 +412,12 @@ func (s *RepoState) WaitFor(t *testing.T, session agents.Session, pattern string } } +// CheckpointTimeout returns the checkpoint polling timeout scaled by the +// agent's TimeoutMultiplier. The base timeout is 15 seconds. +func (s *RepoState) CheckpointTimeout() time.Duration { + return time.Duration(float64(15*time.Second) * s.Agent.TimeoutMultiplier()) +} + // IsExternalAgent returns true if the agent implements the ExternalAgent // interface and reports itself as external. func (s *RepoState) IsExternalAgent() bool { From 684282deaffbd06b413b64fd04932185a1142a6f Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Thu, 19 Mar 2026 21:41:21 +0100 Subject: [PATCH 2/2] Work around Copilot CLI v1.0.8+ hook regression in non-interactive mode Copilot CLI v1.0.8 changed hook loading to require folder trust confirmation before loading repo-level hooks. In non-interactive (-p) mode there is no trust dialog, so hooks silently never fire. Pre-add the test repo directory to ~/.copilot/config.json trusted_folders before invoking copilot -p. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 44debfa7ba4f --- e2e/agents/copilot-cli.go | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/e2e/agents/copilot-cli.go b/e2e/agents/copilot-cli.go index 27b79ebf5..16159c0ed 100644 --- a/e2e/agents/copilot-cli.go +++ b/e2e/agents/copilot-cli.go @@ -2,11 +2,14 @@ package agents import ( "context" + "encoding/json" "errors" "fmt" "os" "os/exec" + "path/filepath" "strings" + "sync" "syscall" "time" ) @@ -58,6 +61,13 @@ func (c *CopilotCLI) Bootstrap() error { } func (c *CopilotCLI) RunPrompt(ctx context.Context, dir string, prompt string, opts ...Option) (Output, error) { + // Copilot CLI v1.0.8+ requires folder trust before loading repo-level + // hooks. In non-interactive (-p) mode there is no trust dialog, so we + // pre-add the directory to the trusted_folders list. + if err := ensureCopilotTrust(dir); err != nil { + return Output{}, fmt.Errorf("ensure copilot folder trust: %w", err) + } + cfg := &runConfig{Model: "gpt-4.1"} for _, o := range opts { o(cfg) @@ -176,3 +186,63 @@ func (c *CopilotCLI) StartSession(ctx context.Context, dir string) (Session, err return s, nil } + +// copilotTrustMu serializes concurrent read-modify-write of ~/.copilot/config.json. +var copilotTrustMu sync.Mutex + +// ensureCopilotTrust adds dir to ~/.copilot/config.json trusted_folders if not +// already present. Copilot CLI v1.0.8+ requires folder trust before loading +// repo-level hooks; without this, hooks silently don't fire in -p mode. +func ensureCopilotTrust(dir string) error { + absDir, err := filepath.Abs(dir) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + configPath := filepath.Join(home, ".copilot", "config.json") + + copilotTrustMu.Lock() + defer copilotTrustMu.Unlock() + + // Read existing config (or start fresh if it doesn't exist). + var cfg map[string]any + data, err := os.ReadFile(configPath) + switch { + case err == nil: + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse copilot config %s: %w", configPath, err) + } + case errors.Is(err, os.ErrNotExist): + cfg = make(map[string]any) + default: + return fmt.Errorf("read copilot config %s: %w", configPath, err) + } + + // Check if already trusted. + var folders []any + if raw, ok := cfg["trusted_folders"]; ok { + if arr, ok := raw.([]any); ok { + folders = arr + } + } + for _, f := range folders { + if s, ok := f.(string); ok && s == absDir { + return nil // already trusted + } + } + + // Add and write back. + cfg["trusted_folders"] = append(folders, absDir) + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + return os.WriteFile(configPath, out, 0o644) +}