story-kit: merge 117_story_show_startup_reconciliation_progress_in_ui

This commit is contained in:
Dave
2026-02-23 22:50:57 +00:00
parent e3d9813707
commit 85fddcb71a
7 changed files with 341 additions and 8 deletions

View File

@@ -51,7 +51,13 @@ export type WsResponse =
tool_name: string; tool_name: string;
tool_input: Record<string, unknown>; tool_input: Record<string, unknown>;
} }
| { type: "tool_activity"; tool_name: string }; | { type: "tool_activity"; tool_name: string }
| {
type: "reconciliation_progress";
story_id: string;
status: string;
message: string;
};
export interface ProviderConfig { export interface ProviderConfig {
provider: string; provider: string;
@@ -262,6 +268,11 @@ export class ChatWebSocket {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
) => void; ) => void;
private onActivity?: (toolName: string) => void; private onActivity?: (toolName: string) => void;
private onReconciliationProgress?: (
storyId: string,
status: string,
message: string,
) => void;
private connected = false; private connected = false;
private closeTimer?: number; private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH; private wsPath = DEFAULT_WS_PATH;
@@ -305,6 +316,12 @@ export class ChatWebSocket {
data.tool_input, data.tool_input,
); );
if (data.type === "tool_activity") this.onActivity?.(data.tool_name); if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
if (data.type === "reconciliation_progress")
this.onReconciliationProgress?.(
data.story_id,
data.status,
data.message,
);
} catch (err) { } catch (err) {
this.onError?.(String(err)); this.onError?.(String(err));
} }
@@ -345,6 +362,11 @@ export class ChatWebSocket {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
) => void; ) => void;
onActivity?: (toolName: string) => void; onActivity?: (toolName: string) => void;
onReconciliationProgress?: (
storyId: string,
status: string,
message: string,
) => void;
}, },
wsPath = DEFAULT_WS_PATH, wsPath = DEFAULT_WS_PATH,
) { ) {
@@ -355,6 +377,7 @@ export class ChatWebSocket {
this.onPipelineState = handlers.onPipelineState; this.onPipelineState = handlers.onPipelineState;
this.onPermissionRequest = handlers.onPermissionRequest; this.onPermissionRequest = handlers.onPermissionRequest;
this.onActivity = handlers.onActivity; this.onActivity = handlers.onActivity;
this.onReconciliationProgress = handlers.onReconciliationProgress;
this.wsPath = wsPath; this.wsPath = wsPath;
this.shouldReconnect = true; this.shouldReconnect = true;

View File

@@ -18,6 +18,11 @@ type WsHandlers = {
onUpdate: (history: Message[]) => void; onUpdate: (history: Message[]) => void;
onSessionId: (sessionId: string) => void; onSessionId: (sessionId: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
onReconciliationProgress: (
storyId: string,
status: string,
message: string,
) => void;
}; };
let capturedWsHandlers: WsHandlers | null = null; let capturedWsHandlers: WsHandlers | null = null;
@@ -310,3 +315,81 @@ describe("Chat input Shift+Enter behavior", () => {
expect((input as HTMLTextAreaElement).value).toBe("Hello"); expect((input as HTMLTextAreaElement).value).toBe("Hello");
}); });
}); });
describe("Chat reconciliation banner", () => {
beforeEach(() => {
capturedWsHandlers = null;
setupMocks();
});
it("shows banner when a non-done reconciliation event is received", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onReconciliationProgress(
"42_story_test",
"checking",
"Checking for committed work in 2_current/",
);
});
expect(
await screen.findByTestId("reconciliation-banner"),
).toBeInTheDocument();
expect(
await screen.findByText("Reconciling startup state..."),
).toBeInTheDocument();
});
it("shows event message in the banner", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onReconciliationProgress(
"42_story_test",
"gates_running",
"Running acceptance gates…",
);
});
expect(
await screen.findByText(/Running acceptance gates/),
).toBeInTheDocument();
});
it("dismisses banner when done event is received", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
act(() => {
capturedWsHandlers?.onReconciliationProgress(
"42_story_test",
"checking",
"Checking for committed work",
);
});
expect(
await screen.findByTestId("reconciliation-banner"),
).toBeInTheDocument();
act(() => {
capturedWsHandlers?.onReconciliationProgress(
"",
"done",
"Startup reconciliation complete.",
);
});
await waitFor(() => {
expect(
screen.queryByTestId("reconciliation-banner"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -64,6 +64,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [isNarrowScreen, setIsNarrowScreen] = useState( const [isNarrowScreen, setIsNarrowScreen] = useState(
window.innerWidth < NARROW_BREAKPOINT, window.innerWidth < NARROW_BREAKPOINT,
); );
const [reconciliationActive, setReconciliationActive] = useState(false);
const [reconciliationEvents, setReconciliationEvents] = useState<
{ id: string; storyId: string; status: string; message: string }[]
>([]);
const reconciliationEventIdRef = useRef(0);
const wsRef = useRef<ChatWebSocket | null>(null); const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -197,6 +202,19 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onActivity: (toolName) => { onActivity: (toolName) => {
setActivityStatus(formatToolActivity(toolName)); setActivityStatus(formatToolActivity(toolName));
}, },
onReconciliationProgress: (storyId, status, message) => {
if (status === "done") {
setReconciliationActive(false);
} else {
setReconciliationActive(true);
setReconciliationEvents((prev) => {
const id = String(reconciliationEventIdRef.current++);
const next = [...prev, { id, storyId, status, message }];
// Keep only the last 8 events to avoid the banner growing too tall.
return next.slice(-8);
});
}
},
}); });
return () => { return () => {
@@ -679,6 +697,52 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div> </div>
</div> </div>
{/* Startup reconciliation progress banner */}
{reconciliationActive && (
<div
data-testid="reconciliation-banner"
style={{
padding: "6px 24px",
background: "#1c2a1c",
borderTop: "1px solid #2d4a2d",
fontSize: "0.8em",
color: "#7ec87e",
maxHeight: "100px",
overflowY: "auto",
flexShrink: 0,
}}
>
<div
style={{
fontWeight: 600,
marginBottom: "2px",
color: "#a0d4a0",
}}
>
Reconciling startup state...
</div>
{reconciliationEvents.map((evt) => (
<div
key={evt.id}
style={{
color:
evt.status === "failed"
? "#d07070"
: evt.status === "advanced"
? "#80c880"
: "#666",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{evt.storyId ? `[${evt.storyId}] ` : ""}
{evt.message}
</div>
))}
</div>
)}
{/* Chat input pinned at bottom of left column */} {/* Chat input pinned at bottom of left column */}
<div <div
style={{ style={{

View File

@@ -11,6 +11,18 @@ use std::process::Command;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::broadcast; use tokio::sync::broadcast;
/// Events emitted during server startup reconciliation to broadcast real-time
/// progress to connected WebSocket clients.
#[derive(Debug, Clone, Serialize)]
pub struct ReconciliationEvent {
/// The story being reconciled, or empty string for the overall "done" event.
pub story_id: String,
/// Coarse status: "checking", "gates_running", "advanced", "skipped", "failed", "done"
pub status: String,
/// Human-readable details.
pub message: String,
}
/// Build the composite key used to track agents in the pool. /// Build the composite key used to track agents in the pool.
fn composite_key(story_id: &str, agent_name: &str) -> String { fn composite_key(story_id: &str, agent_name: &str) -> String {
format!("{story_id}:{agent_name}") format!("{story_id}:{agent_name}")
@@ -1198,11 +1210,20 @@ impl AgentPool {
/// start a fresh agent to retry. /// start a fresh agent to retry.
/// 4. Stories in `4_merge/` are left for `auto_assign_available_work` to handle via a /// 4. Stories in `4_merge/` are left for `auto_assign_available_work` to handle via a
/// fresh mergemaster (squash-merge must be re-executed by the mergemaster agent). /// fresh mergemaster (squash-merge must be re-executed by the mergemaster agent).
pub async fn reconcile_on_startup(&self, project_root: &Path) { pub async fn reconcile_on_startup(
&self,
project_root: &Path,
progress_tx: &broadcast::Sender<ReconciliationEvent>,
) {
let worktrees = match worktree::list_worktrees(project_root) { let worktrees = match worktree::list_worktrees(project_root) {
Ok(wt) => wt, Ok(wt) => wt,
Err(e) => { Err(e) => {
eprintln!("[startup:reconcile] Failed to list worktrees: {e}"); eprintln!("[startup:reconcile] Failed to list worktrees: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: String::new(),
status: "done".to_string(),
message: format!("Reconciliation failed: {e}"),
});
return; return;
} }
}; };
@@ -1222,6 +1243,12 @@ impl AgentPool {
continue; continue;
} }
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "checking".to_string(),
message: format!("Checking for committed work in {stage_dir}/"),
});
// Check whether the worktree has commits ahead of the base branch. // Check whether the worktree has commits ahead of the base branch.
let wt_path_for_check = wt_path.clone(); let wt_path_for_check = wt_path.clone();
let has_work = tokio::task::spawn_blocking(move || { let has_work = tokio::task::spawn_blocking(move || {
@@ -1234,12 +1261,22 @@ impl AgentPool {
eprintln!( eprintln!(
"[startup:reconcile] No committed work for '{story_id}' in {stage_dir}/; skipping." "[startup:reconcile] No committed work for '{story_id}' in {stage_dir}/; skipping."
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "skipped".to_string(),
message: "No committed work found; skipping.".to_string(),
});
continue; continue;
} }
eprintln!( eprintln!(
"[startup:reconcile] Found committed work for '{story_id}' in {stage_dir}/. Running acceptance gates." "[startup:reconcile] Found committed work for '{story_id}' in {stage_dir}/. Running acceptance gates."
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "gates_running".to_string(),
message: "Running acceptance gates…".to_string(),
});
// Run acceptance gates on the worktree. // Run acceptance gates on the worktree.
let wt_path_for_gates = wt_path.clone(); let wt_path_for_gates = wt_path.clone();
@@ -1253,12 +1290,22 @@ impl AgentPool {
Ok(Ok(pair)) => pair, Ok(Ok(pair)) => pair,
Ok(Err(e)) => { Ok(Err(e)) => {
eprintln!("[startup:reconcile] Gate check error for '{story_id}': {e}"); eprintln!("[startup:reconcile] Gate check error for '{story_id}': {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Gate error: {e}"),
});
continue; continue;
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"[startup:reconcile] Gate check task panicked for '{story_id}': {e}" "[startup:reconcile] Gate check task panicked for '{story_id}': {e}"
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Gate task panicked: {e}"),
});
continue; continue;
} }
}; };
@@ -1268,6 +1315,11 @@ impl AgentPool {
"[startup:reconcile] Gates failed for '{story_id}': {gate_output}\n\ "[startup:reconcile] Gates failed for '{story_id}': {gate_output}\n\
Leaving in {stage_dir}/ for auto-assign to restart the agent." Leaving in {stage_dir}/ for auto-assign to restart the agent."
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: "Gates failed; will be retried by auto-assign.".to_string(),
});
continue; continue;
} }
@@ -1279,8 +1331,18 @@ impl AgentPool {
// Coder stage → advance to QA. // Coder stage → advance to QA.
if let Err(e) = move_story_to_qa(project_root, story_id) { if let Err(e) = move_story_to_qa(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}"); eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to QA: {e}"),
});
} else { } else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/."); eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/.");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "advanced".to_string(),
message: "Gates passed — moved to QA.".to_string(),
});
} }
} else if stage_dir == "3_qa" { } else if stage_dir == "3_qa" {
// QA stage → run coverage gate before advancing to merge. // QA stage → run coverage gate before advancing to merge.
@@ -1295,12 +1357,22 @@ impl AgentPool {
eprintln!( eprintln!(
"[startup:reconcile] Coverage gate error for '{story_id}': {e}" "[startup:reconcile] Coverage gate error for '{story_id}': {e}"
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Coverage gate error: {e}"),
});
continue; continue;
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"[startup:reconcile] Coverage gate panicked for '{story_id}': {e}" "[startup:reconcile] Coverage gate panicked for '{story_id}': {e}"
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Coverage gate panicked: {e}"),
});
continue; continue;
} }
}; };
@@ -1310,17 +1382,39 @@ impl AgentPool {
eprintln!( eprintln!(
"[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}" "[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}"
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to merge: {e}"),
});
} else { } else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/."); eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/.");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "advanced".to_string(),
message: "Gates passed — moved to merge.".to_string(),
});
} }
} else { } else {
eprintln!( eprintln!(
"[startup:reconcile] Coverage gate failed for '{story_id}': {coverage_output}\n\ "[startup:reconcile] Coverage gate failed for '{story_id}': {coverage_output}\n\
Leaving in 3_qa/ for auto-assign to restart the QA agent." Leaving in 3_qa/ for auto-assign to restart the QA agent."
); );
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: "Coverage gate failed; will be retried.".to_string(),
});
} }
} }
} }
// Signal that reconciliation is complete.
let _ = progress_tx.send(ReconciliationEvent {
story_id: String::new(),
status: "done".to_string(),
message: "Startup reconciliation complete.".to_string(),
});
} }
/// Test helper: inject an agent with a completion report and project_root /// Test helper: inject an agent with a completion report and project_root
@@ -3566,8 +3660,28 @@ name = "qa"
async fn reconcile_on_startup_noop_when_no_worktrees() { async fn reconcile_on_startup_noop_when_no_worktrees() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let pool = AgentPool::new(3001); let pool = AgentPool::new(3001);
let (tx, _rx) = broadcast::channel(16);
// Should not panic; no worktrees to reconcile. // Should not panic; no worktrees to reconcile.
pool.reconcile_on_startup(tmp.path()).await; pool.reconcile_on_startup(tmp.path(), &tx).await;
}
#[tokio::test]
async fn reconcile_on_startup_emits_done_event() {
let tmp = tempfile::tempdir().unwrap();
let pool = AgentPool::new(3001);
let (tx, mut rx) = broadcast::channel::<ReconciliationEvent>(16);
pool.reconcile_on_startup(tmp.path(), &tx).await;
// Collect all events; the last must be "done".
let mut events: Vec<ReconciliationEvent> = Vec::new();
while let Ok(evt) = rx.try_recv() {
events.push(evt);
}
assert!(
events.iter().any(|e| e.status == "done"),
"reconcile_on_startup must emit a 'done' event; got: {:?}",
events.iter().map(|e| &e.status).collect::<Vec<_>>()
);
} }
#[tokio::test] #[tokio::test]
@@ -3588,7 +3702,8 @@ name = "qa"
init_git_repo(&wt_dir); init_git_repo(&wt_dir);
let pool = AgentPool::new(3001); let pool = AgentPool::new(3001);
pool.reconcile_on_startup(root).await; let (tx, _rx) = broadcast::channel(16);
pool.reconcile_on_startup(root, &tx).await;
// Story should still be in 2_current/ — nothing was reconciled. // Story should still be in 2_current/ — nothing was reconciled.
assert!( assert!(
@@ -3671,7 +3786,8 @@ name = "qa"
); );
let pool = AgentPool::new(3001); let pool = AgentPool::new(3001);
pool.reconcile_on_startup(root).await; let (tx, _rx) = broadcast::channel(16);
pool.reconcile_on_startup(root, &tx).await;
// In the test env, cargo clippy will fail (no Cargo.toml) so gates fail // In the test env, cargo clippy will fail (no Cargo.toml) so gates fail
// and the story stays in 2_current/. The important assertion is that // and the story stays in 2_current/. The important assertion is that

View File

@@ -1,4 +1,4 @@
use crate::agents::AgentPool; use crate::agents::{AgentPool, ReconciliationEvent};
use crate::io::watcher::WatcherEvent; use crate::io::watcher::WatcherEvent;
use crate::state::SessionState; use crate::state::SessionState;
use crate::store::JsonFileStore; use crate::store::JsonFileStore;
@@ -26,6 +26,10 @@ pub struct AppContext {
/// Broadcast channel for filesystem watcher events. WebSocket handlers /// Broadcast channel for filesystem watcher events. WebSocket handlers
/// subscribe to this to push lifecycle notifications to connected clients. /// subscribe to this to push lifecycle notifications to connected clients.
pub watcher_tx: broadcast::Sender<WatcherEvent>, pub watcher_tx: broadcast::Sender<WatcherEvent>,
/// Broadcast channel for startup reconciliation progress events.
/// WebSocket handlers subscribe to this to push real-time reconciliation
/// updates to connected clients.
pub reconciliation_tx: broadcast::Sender<ReconciliationEvent>,
/// Sender for permission requests originating from the MCP /// Sender for permission requests originating from the MCP
/// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`] /// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`]
/// and awaits the oneshot response. /// and awaits the oneshot response.
@@ -42,6 +46,7 @@ impl AppContext {
*state.project_root.lock().unwrap() = Some(project_root.clone()); *state.project_root.lock().unwrap() = Some(project_root.clone());
let store_path = project_root.join(".story_kit_store.json"); let store_path = project_root.join(".story_kit_store.json");
let (watcher_tx, _) = broadcast::channel(64); let (watcher_tx, _) = broadcast::channel(64);
let (reconciliation_tx, _) = broadcast::channel(64);
let (perm_tx, perm_rx) = mpsc::unbounded_channel(); let (perm_tx, perm_rx) = mpsc::unbounded_channel();
Self { Self {
state: Arc::new(state), state: Arc::new(state),
@@ -49,6 +54,7 @@ impl AppContext {
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())), workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
agents: Arc::new(AgentPool::new(3001)), agents: Arc::new(AgentPool::new(3001)),
watcher_tx, watcher_tx,
reconciliation_tx,
perm_tx, perm_tx,
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)), perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
} }

View File

@@ -79,6 +79,14 @@ enum WsResponse {
ToolActivity { ToolActivity {
tool_name: String, tool_name: String,
}, },
/// Real-time progress from the server startup reconciliation pass.
/// `status` is one of: "checking", "gates_running", "advanced", "skipped",
/// "failed", "done". `story_id` is empty for the overall "done" event.
ReconciliationProgress {
story_id: String,
status: String,
message: String,
},
} }
impl From<WatcherEvent> for WsResponse { impl From<WatcherEvent> for WsResponse {
@@ -155,6 +163,30 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
} }
}); });
// Subscribe to startup reconciliation events and forward them to the client.
let tx_reconcile = tx.clone();
let mut reconcile_rx = ctx.reconciliation_tx.subscribe();
tokio::spawn(async move {
loop {
match reconcile_rx.recv().await {
Ok(evt) => {
if tx_reconcile
.send(WsResponse::ReconciliationProgress {
story_id: evt.story_id,
status: evt.status,
message: evt.message,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Map of pending permission request_id → oneshot responder. // Map of pending permission request_id → oneshot responder.
// Permission requests arrive from the MCP `prompt_permission` tool via // Permission requests arrive from the MCP `prompt_permission` tool via
// `ctx.perm_rx` and are forwarded to the client as `PermissionRequest`. // `ctx.perm_rx` and are forwarded to the client as `PermissionRequest`.

View File

@@ -66,12 +66,18 @@ async fn main() -> Result<(), std::io::Error> {
} }
} }
// Reconciliation progress channel: startup reconciliation → WebSocket clients.
let (reconciliation_tx, _) =
broadcast::channel::<agents::ReconciliationEvent>(64);
// Permission channel: MCP prompt_permission → WebSocket handler. // Permission channel: MCP prompt_permission → WebSocket handler.
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel(); let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
// Capture project root and agents Arc before ctx is consumed by build_routes. // Capture project root, agents Arc, and reconciliation sender before ctx
// is consumed by build_routes.
let startup_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone(); let startup_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
let startup_agents = Arc::clone(&agents); let startup_agents = Arc::clone(&agents);
let startup_reconciliation_tx = reconciliation_tx.clone();
let ctx = AppContext { let ctx = AppContext {
state: app_state, state: app_state,
@@ -79,6 +85,7 @@ async fn main() -> Result<(), std::io::Error> {
workflow, workflow,
agents, agents,
watcher_tx, watcher_tx,
reconciliation_tx,
perm_tx, perm_tx,
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)), perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
}; };
@@ -94,7 +101,9 @@ async fn main() -> Result<(), std::io::Error> {
slog!( slog!(
"[startup] Reconciling completed worktrees from previous session." "[startup] Reconciling completed worktrees from previous session."
); );
startup_agents.reconcile_on_startup(&root).await; startup_agents
.reconcile_on_startup(&root, &startup_reconciliation_tx)
.await;
slog!( slog!(
"[auto-assign] Scanning pipeline stages for unassigned work." "[auto-assign] Scanning pipeline stages for unassigned work."
); );