Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Sources/Crow/App/IssueTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1626,16 +1626,18 @@ final class IssueTracker {
}

/// True when the managed terminal for the session is at agent-launched
/// readiness with the agent in `.idle` (not working, waiting on input,
/// or already done). Both gates are required: a pre-launch terminal
/// can't be "stalled" because the agent never had a chance to run, and
/// firing into a busy agent would just interrupt productive work.
/// readiness with the agent available to accept a prompt — either
/// `.idle` (fresh, never run) or `.done` (finished a top-level task and
/// waiting). `.working` and `.waiting` still gate: firing into a busy
/// or blocked agent would interrupt it. A pre-launch terminal also
/// gates, because the agent never had a chance to run.
private func isManagedTerminalIdle(sessionID: UUID) -> Bool {
guard let managedTerminal = appState.terminals(for: sessionID).first(where: { $0.isManaged }) else {
return false
}
guard appState.terminalReadiness[managedTerminal.id] == .agentLaunched else { return false }
return appState.hookState(for: sessionID).activityState == .idle
let state = appState.hookState(for: sessionID).activityState
return state == .idle || state == .done
}

/// True when no prior dispatch is recorded for this PR or the cooldown
Expand Down
35 changes: 28 additions & 7 deletions Tests/CrowTests/IssueTrackerNeedsRefineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct IssueTrackerNeedsRefineTests {
private func makeTracker(
respondToChangesRequested: Bool = true,
readiness: TerminalReadiness = .agentLaunched,
agentIdle: Bool = true
activityState: AgentActivityState = .idle
) -> (tracker: IssueTracker, sessionID: UUID, captured: TransitionCapture) {
let state = AppState()
let session = Session(name: "feature/stateless-test", kind: .work)
Expand All @@ -50,11 +50,7 @@ struct IssueTrackerNeedsRefineTests {
)
state.terminals[session.id] = [terminal]
state.terminalReadiness[terminal.id] = readiness
// Hook state activity defaults to .idle from AppState — set
// explicitly when the test wants it busy.
if !agentIdle {
state.hookState(for: session.id).activityState = .working
}
state.hookState(for: session.id).activityState = activityState

let tracker = IssueTracker(appState: state, providerManager: ProviderManager())
tracker.respondToChangesRequestedProvider = { respondToChangesRequested }
Expand Down Expand Up @@ -185,13 +181,38 @@ struct IssueTrackerNeedsRefineTests {

@Test
func busyAgentSuppressesDispatch() {
let (tracker, _, captured) = makeTracker(agentIdle: false)
let (tracker, _, captured) = makeTracker(activityState: .working)
tracker.seenPRs.insert(prURL)
let pr = makeViewerPR(lastChangesRequestedAt: reviewAt, lastSubstantiveCommitAt: beforeReview)
tracker.applyPRStatuses(viewerPRs: [pr])
#expect(captured.changesRequestedCount == 0)
}

@Test
func waitingAgentSuppressesDispatch() {
// `.waiting` means the agent is blocked on input (e.g. a permission
// prompt). Refine must not fire — same reasoning as `.working`.
let (tracker, _, captured) = makeTracker(activityState: .waiting)
tracker.seenPRs.insert(prURL)
let pr = makeViewerPR(lastChangesRequestedAt: reviewAt, lastSubstantiveCommitAt: beforeReview)
tracker.applyPRStatuses(viewerPRs: [pr])
#expect(captured.changesRequestedCount == 0)
}

@Test
func doneAgentDispatches() {
// CROW-510 regression: after an agent finishes its first top-level
// task, the hook state lands on `.done` and stays there until the
// next prompt. Refine must dispatch in that state — historically the
// gate only accepted `.idle`, so the rule never fired after the
// agent's first task.
let (tracker, _, captured) = makeTracker(activityState: .done)
tracker.seenPRs.insert(prURL)
let pr = makeViewerPR(lastChangesRequestedAt: reviewAt, lastSubstantiveCommitAt: beforeReview)
tracker.applyPRStatuses(viewerPRs: [pr])
#expect(captured.changesRequestedCount == 1)
}

@Test
func preLaunchTerminalSuppressesDispatch() {
let (tracker, _, captured) = makeTracker(readiness: .uninitialized)
Expand Down
Loading