huskies: merge 820

This commit is contained in:
dave
2026-04-29 17:15:01 +00:00
parent c84786364a
commit 8a42839b37
15 changed files with 180 additions and 46 deletions
+10
View File
@@ -209,6 +209,16 @@ body,
} }
} }
/* Spinner for in-progress deterministic merges */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Agent lozenge appearance animation (simulates arriving from agents panel) */ /* Agent lozenge appearance animation (simulates arriving from agents panel) */
@keyframes agentAppear { @keyframes agentAppear {
from { from {
+1
View File
@@ -267,6 +267,7 @@ describe("ChatWebSocket", () => {
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
}; };
instances[1].simulateMessage({ type: "pipeline_state", ...freshState }); instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
+3
View File
@@ -69,6 +69,8 @@ export interface PipelineState {
qa: PipelineStageItem[]; qa: PipelineStageItem[];
merge: PipelineStageItem[]; merge: PipelineStageItem[];
done: PipelineStageItem[]; done: PipelineStageItem[];
/** Story IDs that currently have a deterministic merge in progress. */
deterministic_merges_in_flight: string[];
} }
/** A message received from the Huskies server over WebSocket. */ /** A message received from the Huskies server over WebSocket. */
@@ -84,6 +86,7 @@ export type WsResponse =
qa: PipelineStageItem[]; qa: PipelineStageItem[];
merge: PipelineStageItem[]; merge: PipelineStageItem[];
done: PipelineStageItem[]; done: PipelineStageItem[];
deterministic_merges_in_flight: string[];
} }
| { | {
type: "permission_request"; type: "permission_request";
+2
View File
@@ -123,6 +123,8 @@ export class ChatWebSocket {
qa: data.qa, qa: data.qa,
merge: data.merge, merge: data.merge,
done: data.done, done: data.done,
deterministic_merges_in_flight:
data.deterministic_merges_in_flight ?? [],
}); });
if (data.type === "permission_request") if (data.type === "permission_request")
this.onPermissionRequest?.( this.onPermissionRequest?.(
+60 -46
View File
@@ -110,52 +110,66 @@ export function ChatPipelinePanel({
configVersion={agentConfigVersion} configVersion={agentConfigVersion}
stateVersion={agentStateVersion} stateVersion={agentStateVersion}
/> />
<StagePanel {(() => {
title="Done" const mergesInFlight = new Set(
items={pipeline.done ?? []} pipeline.deterministic_merges_in_flight ?? [],
costs={storyTokenCosts} );
onItemClick={(item) => onSelectWorkItem(item.story_id)} return (
onStopAgent={onStopAgent} <>
onDeleteItem={onDeleteItem} <StagePanel
/> title="Done"
<StagePanel items={pipeline.done ?? []}
title="To Merge" costs={storyTokenCosts}
items={pipeline.merge} onItemClick={(item) => onSelectWorkItem(item.story_id)}
costs={storyTokenCosts} onStopAgent={onStopAgent}
onItemClick={(item) => onSelectWorkItem(item.story_id)} onDeleteItem={onDeleteItem}
onStopAgent={onStopAgent} mergesInFlight={mergesInFlight}
onDeleteItem={onDeleteItem} />
/> <StagePanel
<StagePanel title="To Merge"
title="QA" items={pipeline.merge}
items={pipeline.qa} costs={storyTokenCosts}
costs={storyTokenCosts} onItemClick={(item) => onSelectWorkItem(item.story_id)}
onItemClick={(item) => onSelectWorkItem(item.story_id)} onStopAgent={onStopAgent}
onStopAgent={onStopAgent} onDeleteItem={onDeleteItem}
onDeleteItem={onDeleteItem} mergesInFlight={mergesInFlight}
/> />
<StagePanel <StagePanel
title="Current" title="QA"
items={pipeline.current} items={pipeline.qa}
costs={storyTokenCosts} costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)} onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster} onStopAgent={onStopAgent}
busyAgentNames={busyAgentNames} onDeleteItem={onDeleteItem}
onStartAgent={onStartAgent} mergesInFlight={mergesInFlight}
onStopAgent={onStopAgent} />
onDeleteItem={onDeleteItem} <StagePanel
/> title="Current"
<StagePanel items={pipeline.current}
title="Backlog" costs={storyTokenCosts}
items={pipeline.backlog} onItemClick={(item) => onSelectWorkItem(item.story_id)}
costs={storyTokenCosts} agentRoster={agentRoster}
onItemClick={(item) => onSelectWorkItem(item.story_id)} busyAgentNames={busyAgentNames}
agentRoster={agentRoster} onStartAgent={onStartAgent}
busyAgentNames={busyAgentNames} onStopAgent={onStopAgent}
onStartAgent={onStartAgent} onDeleteItem={onDeleteItem}
onStopAgent={onStopAgent} mergesInFlight={mergesInFlight}
onDeleteItem={onDeleteItem} />
/> <StagePanel
title="Backlog"
items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
</>
);
})()}
<ServerLogsPanel logs={combinedLogs} /> <ServerLogsPanel logs={combinedLogs} />
</> </>
)} )}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
...overrides, ...overrides,
}; };
} }
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
...overrides, ...overrides,
}; };
} }
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
...overrides, ...overrides,
}; };
} }
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
...overrides, ...overrides,
}; };
} }
@@ -324,4 +324,71 @@ describe("StagePanel", () => {
screen.queryByTestId("merge-failure-reason-31_story_no_failure"), screen.queryByTestId("merge-failure-reason-31_story_no_failure"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("shows merge-in-flight icon when story is in mergesInFlight set", () => {
const items: PipelineStageItem[] = [
{
story_id: "40_story_merging",
name: "Merging Story",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
depends_on: null,
},
];
const mergesInFlight = new Set(["40_story_merging"]);
render(
<StagePanel title="To Merge" items={items} mergesInFlight={mergesInFlight} />,
);
expect(
screen.getByTestId("merge-in-flight-icon-40_story_merging"),
).toBeInTheDocument();
});
it("does not show merge-in-flight icon when story is not in mergesInFlight set", () => {
const items: PipelineStageItem[] = [
{
story_id: "41_story_not_merging",
name: "Idle Story",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
depends_on: null,
},
];
const mergesInFlight = new Set(["99_story_other"]);
render(
<StagePanel
title="To Merge"
items={items}
mergesInFlight={mergesInFlight}
/>,
);
expect(
screen.queryByTestId("merge-in-flight-icon-41_story_not_merging"),
).not.toBeInTheDocument();
});
it("does not show merge-in-flight icon when mergesInFlight prop is absent", () => {
const items: PipelineStageItem[] = [
{
story_id: "42_story_no_prop",
name: "No Prop Story",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
depends_on: null,
},
];
render(<StagePanel title="To Merge" items={items} />);
expect(
screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"),
).not.toBeInTheDocument();
});
}); });
+16
View File
@@ -53,6 +53,8 @@ interface StagePanelProps {
busyAgentNames?: Set<string>; busyAgentNames?: Set<string>;
/** Called when the user requests to start an agent on a story. */ /** Called when the user requests to start an agent on a story. */
onStartAgent?: (storyId: string, agentName?: string) => void; onStartAgent?: (storyId: string, agentName?: string) => void;
/** Set of story IDs that currently have a deterministic merge in progress. */
mergesInFlight?: Set<string>;
} }
function AgentLozenge({ function AgentLozenge({
@@ -259,6 +261,7 @@ export function StagePanel({
agentRoster, agentRoster,
busyAgentNames, busyAgentNames,
onStartAgent, onStartAgent,
mergesInFlight,
}: StagePanelProps) { }: StagePanelProps) {
const showStartButton = const showStartButton =
Boolean(onStartAgent) && Boolean(onStartAgent) &&
@@ -355,6 +358,19 @@ export function StagePanel({
</span> </span>
)} )}
{mergesInFlight?.has(item.story_id) && (
<span
data-testid={`merge-in-flight-icon-${item.story_id}`}
title="Deterministic merge in progress"
style={{
display: "inline-block",
marginRight: "6px",
animation: "spin 1s linear infinite",
}}
>
</span>
)}
{itemNumber && ( {itemNumber && (
<span <span
style={{ style={{
+1
View File
@@ -103,6 +103,7 @@ export function useChatWebSocket({
qa: [], qa: [],
merge: [], merge: [],
done: [], done: [],
deterministic_merges_in_flight: [],
}); });
const [pipelineVersion, setPipelineVersion] = useState(0); const [pipelineVersion, setPipelineVersion] = useState(0);
const [reconciliationActive, setReconciliationActive] = useState(false); const [reconciliationActive, setReconciliationActive] = useState(false);
+9
View File
@@ -57,6 +57,8 @@ pub struct PipelineState {
pub qa: Vec<UpcomingStory>, pub qa: Vec<UpcomingStory>,
pub merge: Vec<UpcomingStory>, pub merge: Vec<UpcomingStory>,
pub done: Vec<UpcomingStory>, pub done: Vec<UpcomingStory>,
/// Story IDs that currently have a deterministic merge in progress.
pub deterministic_merges_in_flight: Vec<String>,
} }
/// Load the full pipeline state (all 5 active stages). /// Load the full pipeline state (all 5 active stages).
@@ -71,12 +73,19 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let typed_items = crate::pipeline_state::read_all_typed(); let typed_items = crate::pipeline_state::read_all_typed();
let deterministic_merges_in_flight = ctx
.services
.agents
.list_running_merges()
.unwrap_or_default();
let mut state = PipelineState { let mut state = PipelineState {
backlog: Vec::new(), backlog: Vec::new(),
current: Vec::new(), current: Vec::new(),
qa: Vec::new(), qa: Vec::new(),
merge: Vec::new(), merge: Vec::new(),
done: Vec::new(), done: Vec::new(),
deterministic_merges_in_flight,
}; };
for item in typed_items { for item in typed_items {
+4
View File
@@ -53,6 +53,7 @@ pub fn pipeline_state_to_response(s: PipelineState) -> WsResponse {
qa: s.qa, qa: s.qa,
merge: s.merge, merge: s.merge,
done: s.done, done: s.done,
deterministic_merges_in_flight: s.deterministic_merges_in_flight,
} }
} }
@@ -238,6 +239,7 @@ mod tests {
blocked: None, blocked: None,
depends_on: None, depends_on: None,
}], }],
deterministic_merges_in_flight: vec![],
}; };
let resp = pipeline_state_to_response(state); let resp = pipeline_state_to_response(state);
let json = serde_json::to_value(&resp).unwrap(); let json = serde_json::to_value(&resp).unwrap();
@@ -260,6 +262,7 @@ mod tests {
qa: vec![], qa: vec![],
merge: vec![], merge: vec![],
done: vec![], done: vec![],
deterministic_merges_in_flight: vec![],
}; };
let resp = pipeline_state_to_response(state); let resp = pipeline_state_to_response(state);
let json = serde_json::to_value(&resp).unwrap(); let json = serde_json::to_value(&resp).unwrap();
@@ -294,6 +297,7 @@ mod tests {
qa: vec![], qa: vec![],
merge: vec![], merge: vec![],
done: vec![], done: vec![],
deterministic_merges_in_flight: vec![],
}; };
let resp: WsResponse = state.into(); let resp: WsResponse = state.into();
let json = serde_json::to_value(&resp).unwrap(); let json = serde_json::to_value(&resp).unwrap();
@@ -54,6 +54,8 @@ pub enum WsResponse {
qa: Vec<UpcomingStory>, qa: Vec<UpcomingStory>,
merge: Vec<UpcomingStory>, merge: Vec<UpcomingStory>,
done: Vec<UpcomingStory>, done: Vec<UpcomingStory>,
/// Story IDs that currently have a deterministic merge in progress.
deterministic_merges_in_flight: Vec<String>,
}, },
/// `.huskies/project.toml` was modified; the frontend should re-fetch the /// `.huskies/project.toml` was modified; the frontend should re-fetch the
/// agent roster. Does NOT trigger a pipeline state refresh. /// agent roster. Does NOT trigger a pipeline state refresh.
@@ -215,6 +217,7 @@ mod tests {
qa: vec![], qa: vec![],
merge: vec![], merge: vec![],
done: vec![], done: vec![],
deterministic_merges_in_flight: vec![],
}; };
let json = serde_json::to_value(&resp).unwrap(); let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "pipeline_state"); assert_eq!(json["type"], "pipeline_state");