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) */
@keyframes agentAppear {
from {
+1
View File
@@ -267,6 +267,7 @@ describe("ChatWebSocket", () => {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
};
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
+3
View File
@@ -69,6 +69,8 @@ export interface PipelineState {
qa: PipelineStageItem[];
merge: 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. */
@@ -84,6 +86,7 @@ export type WsResponse =
qa: PipelineStageItem[];
merge: PipelineStageItem[];
done: PipelineStageItem[];
deterministic_merges_in_flight: string[];
}
| {
type: "permission_request";
+2
View File
@@ -123,6 +123,8 @@ export class ChatWebSocket {
qa: data.qa,
merge: data.merge,
done: data.done,
deterministic_merges_in_flight:
data.deterministic_merges_in_flight ?? [],
});
if (data.type === "permission_request")
this.onPermissionRequest?.(
@@ -110,6 +110,12 @@ export function ChatPipelinePanel({
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
{(() => {
const mergesInFlight = new Set(
pipeline.deterministic_merges_in_flight ?? [],
);
return (
<>
<StagePanel
title="Done"
items={pipeline.done ?? []}
@@ -117,6 +123,7 @@ export function ChatPipelinePanel({
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="To Merge"
@@ -125,6 +132,7 @@ export function ChatPipelinePanel({
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="QA"
@@ -133,6 +141,7 @@ export function ChatPipelinePanel({
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="Current"
@@ -144,6 +153,7 @@ export function ChatPipelinePanel({
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="Backlog"
@@ -155,7 +165,11 @@ export function ChatPipelinePanel({
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
</>
);
})()}
<ServerLogsPanel logs={combinedLogs} />
</>
)}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -324,4 +324,71 @@ describe("StagePanel", () => {
screen.queryByTestId("merge-failure-reason-31_story_no_failure"),
).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>;
/** Called when the user requests to start an agent on a story. */
onStartAgent?: (storyId: string, agentName?: string) => void;
/** Set of story IDs that currently have a deterministic merge in progress. */
mergesInFlight?: Set<string>;
}
function AgentLozenge({
@@ -259,6 +261,7 @@ export function StagePanel({
agentRoster,
busyAgentNames,
onStartAgent,
mergesInFlight,
}: StagePanelProps) {
const showStartButton =
Boolean(onStartAgent) &&
@@ -355,6 +358,19 @@ export function StagePanel({
</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 && (
<span
style={{
+1
View File
@@ -103,6 +103,7 @@ export function useChatWebSocket({
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
});
const [pipelineVersion, setPipelineVersion] = useState(0);
const [reconciliationActive, setReconciliationActive] = useState(false);
+9
View File
@@ -57,6 +57,8 @@ pub struct PipelineState {
pub qa: Vec<UpcomingStory>,
pub merge: 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).
@@ -71,12 +73,19 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
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 {
backlog: Vec::new(),
current: Vec::new(),
qa: Vec::new(),
merge: Vec::new(),
done: Vec::new(),
deterministic_merges_in_flight,
};
for item in typed_items {
+4
View File
@@ -53,6 +53,7 @@ pub fn pipeline_state_to_response(s: PipelineState) -> WsResponse {
qa: s.qa,
merge: s.merge,
done: s.done,
deterministic_merges_in_flight: s.deterministic_merges_in_flight,
}
}
@@ -238,6 +239,7 @@ mod tests {
blocked: None,
depends_on: None,
}],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
let json = serde_json::to_value(&resp).unwrap();
@@ -260,6 +262,7 @@ mod tests {
qa: vec![],
merge: vec![],
done: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
let json = serde_json::to_value(&resp).unwrap();
@@ -294,6 +297,7 @@ mod tests {
qa: vec![],
merge: vec![],
done: vec![],
deterministic_merges_in_flight: vec![],
};
let resp: WsResponse = state.into();
let json = serde_json::to_value(&resp).unwrap();
@@ -54,6 +54,8 @@ pub enum WsResponse {
qa: Vec<UpcomingStory>,
merge: 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
/// agent roster. Does NOT trigger a pipeline state refresh.
@@ -215,6 +217,7 @@ mod tests {
qa: vec![],
merge: vec![],
done: vec![],
deterministic_merges_in_flight: vec![],
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "pipeline_state");