Skip to content

Commit 669e799

Browse files
xlx1212claude
andcommitted
feat(issue-fix): bootstrap LoopX automatically on first start
A fresh user's repository has no .loopx control plane, which previously surfaced as a raw "Failed to read LoopX Issue-Fix state" error banner the moment the panel opened. Pre-bootstrap is a normal state, not an error: status/poll now return null for unconnected repositories, the header says "not connected yet — select issues and press Start", and the Start command runs # LoopX Bootstrap - ok: `True` - dry_run: `False` - project: `C:\codeagent\BitFun` - goal_id: `bitfun-goal` - registry: `C:\codeagent\BitFun\.loopx\registry.json` - state_file: `C:\codeagent\BitFun\.codex\goals\bitfun-goal\ACTIVE_GOAL_STATE.md` - goal_doc: `None` - goal_doc_exists: `False` - runtime_root: `C:\Users\huawei\.codex\loopx` - registry_goal_action: `kept-existing` - state_action: `kept-existing` - execution_profile: `cadence=bounded_progress_segment minimum=multi_surface_or_implementation include=coherent_artifact,targeted_validation,state_writeback spend_rule=spend_only_after_artifact_validation_writeback small_streak_threshold=2` - onboarding_acceptance_required: `True` - autonomous_advance_choice_required: `True` - codex_app_heartbeat: `ask` - heartbeat_opt_in_required: `True` - host_loop_activation_required: `True` - onboarding_todos_written: `False` - global_sync: `True` ## Actions - `C:\codeagent\BitFun\.loopx\registry.json`: wrote (kept-existing) - `C:\codeagent\BitFun\.codex\goals\bitfun-goal\ACTIVE_GOAL_STATE.md`: kept-existing () - `C:\Users\huawei\.codex\loopx\registry.global.json`: synced (bitfun-goal) ## Onboarding Scan - schema: `loopx_project_onboarding_v0` - git_repo: `True` - status_path_count: `10` - recent_commits: `5` - signal_files: `README.md, AGENTS.md, CONTRIBUTING.md, package.json, Cargo.toml` - raw_file_bodies_read: `False` - status_sample: - `A BitFun-Installer/src-tauri/Cargo.lock` - `M src/apps/desktop/src/api/issue_fix_api.rs` - `M src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs` - `M src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx` - `M src/web-ui/src/infrastructure/api/service-api/IssueFixAPI.ts` - recent_commit_sample: - `7a0db930d` feat(issue-fix): bundled LoopX sidecar and three-tier readiness probe - `441cfc424` Merge remote-tracking branch 'origin/main' into feat/loopx-issue-fix - `386d352a3` fix(desktop): also shrink oversized restored windows into the work area - `ddf07a48d` fix(issue-fix): make 'Open panel' work from any scene - `8fe67c5cb` Merge pull request #2064 from ldc111/main ## Proposed Onboarding Candidates 1. [P1] Inspect current uncommitted changes (A BitFun-Installer/src-tauri/Cargo.lock, M src/apps/desktop/src/api/issue_fix_api.rs, M src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs, M src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx, M src/web-ui/src/infrastructure/api/service-api/IssueFixAPI.ts) and decide what belongs in the first LoopX segment before editing. - reason: The repo already has local changes, so the first safe step is ownership and scope classification. - metadata: `advancement_task:repo_status_review` 2. [P1] Summarize the last 5 commits and extract the safest next bounded project follow-up. - reason: Recent commits are a fast signal of current project direction without reading private bodies. - metadata: `advancement_task:commit_summary` 3. [P1] Identify the fastest validation command from package.json, Cargo.toml and record whether it is safe to run now. - reason: Validation entrypoints are visible from top-level project metadata. - metadata: `advancement_task:validation_plan` 4. [P2] Build a compact read-only project map from README.md, AGENTS.md, CONTRIBUTING.md, package.json, Cargo.toml and note authority sources, risks, and first useful handoff. - reason: Top-level project files can seed a useful map before any implementation work. - metadata: `advancement_task:read_only_map` ## Accept Candidate Commands Run only the commands for candidates the user accepts, then refresh state. - `loopx --registry '.loopx\registry.json' todo add --goal-id bitfun-goal --role agent --text '[P1] Inspect current uncommitted changes (A BitFun-Installer/src-tauri/Cargo.lock, M src/apps/desktop/src/api/issue_fix_api.rs, M src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs, M src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx, M src/web-ui/src/infrastructure/api/service-api/IssueFixAPI.ts) and decide what belongs in the first LoopX segment before editing.' --task-class advancement_task --action-kind repo_status_review` - `loopx --registry '.loopx\registry.json' todo add --goal-id bitfun-goal --role agent --text '[P1] Summarize the last 5 commits and extract the safest next bounded project follow-up.' --task-class advancement_task --action-kind commit_summary` - `loopx --registry '.loopx\registry.json' todo add --goal-id bitfun-goal --role agent --text '[P1] Identify the fastest validation command from package.json, Cargo.toml and record whether it is safe to run now.' --task-class advancement_task --action-kind validation_plan` - `loopx --registry '.loopx\registry.json' todo add --goal-id bitfun-goal --role agent --text '[P2] Build a compact read-only project map from README.md, AGENTS.md, CONTRIBUTING.md, package.json, Cargo.toml and note authority sources, risks, and first useful handoff.' --task-class advancement_task --action-kind read_only_map` ## Autonomy And Heartbeat Choice - heartbeat_opt_in_instruction: Ask the user whether to enable the Codex App heartbeat. heartbeat=yes means create or update a recurring Codex App automation from an identity-scoped `loopx heartbeat-prompt --thin` task body; heartbeat=no means keep the goal manual or on-demand. - Ask the user whether Codex may start autonomous advancement. If autonomous=yes, run the quota guard and execute the first accepted Agent Todo; if autonomous=no, stop after writing accepted todos and refresh-state. ## Next Commands - `loopx --registry .loopx\registry.json registry` - `loopx --registry .loopx\registry.json status` - `loopx --registry .loopx\registry.json check --scan-root C:\codeagent\BitFun` - `loopx --format json --registry C:\Users\huawei\.codex\loopx\registry.global.json quota should-run --goal-id bitfun-goal --runtime-profile generic_cli` - `loopx --registry .loopx\registry.json refresh-state --goal-id bitfun-goal` - `loopx --registry C:\Users\huawei\.codex\loopx\registry.global.json status` - `loopx --registry .loopx\registry.json history --goal-id bitfun-goal` ## Install / Update Hint If this local LoopX install is missing or stale, rerun the no-clone installer, refresh PATH, and confirm with loopx doctor before continuing project delivery. ```bash curl -fsSL https://raw.githubusercontent.com/huangruiteng/loopx/main/scripts/install-from-github.sh | bash export PATH="$HOME/.local/bin:$PATH" loopx doctor ``` ## Boundary Note Add .loopx/ and .codex/goals/ to the project .gitignore if the goal state contains private evidence. (BitFun's continuous-repair objective, controller role) plus before writing the first intake todos. Verified end to end on a fresh git repo with the PyInstaller sidecar: bootstrap -> register -> intake -> todo projection -> compact heartbeat generation all succeed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7a0db93 commit 669e799

8 files changed

Lines changed: 5892 additions & 42 deletions

File tree

BitFun-Installer/src-tauri/Cargo.lock

Lines changed: 5731 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/apps/desktop/src/api/issue_fix_api.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,13 @@ pub struct IssueFixAutonomousStatusResponse {
141141
pub async fn issue_fix_autonomous_status(
142142
_state: State<'_, AppState>,
143143
request: IssueFixAutonomousStatusRequest,
144-
) -> Result<IssueFixAutonomousStatusResponse, String> {
144+
) -> Result<Option<IssueFixAutonomousStatusResponse>, String> {
145145
let repository_path = required_repository_path(&request.repository_path)?;
146+
// A repository that has never started continuous fixing has no LoopX
147+
// control plane yet; that is a normal pre-bootstrap state, not an error.
148+
if !AutonomousIssueFix::is_bootstrapped(repository_path) {
149+
return Ok(None);
150+
}
146151
let loopx =
147152
LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?;
148153
let control = AutonomousIssueFix::new(loopx)
@@ -153,7 +158,7 @@ pub async fn issue_fix_autonomous_status(
153158
format!("Failed to read LoopX Issue-Fix state: {error}")
154159
})?;
155160
let host_loop = host_loop_state(&control.goal_id, repository_path).await;
156-
Ok(IssueFixAutonomousStatusResponse { control, host_loop })
161+
Ok(Some(IssueFixAutonomousStatusResponse { control, host_loop }))
157162
}
158163

159164
/// Background-poll response: LoopX todo projection without `quota should-run`.
@@ -172,8 +177,11 @@ pub struct IssueFixAutonomousPollResponse {
172177
pub async fn issue_fix_autonomous_poll(
173178
_state: State<'_, AppState>,
174179
request: IssueFixAutonomousStatusRequest,
175-
) -> Result<IssueFixAutonomousPollResponse, String> {
180+
) -> Result<Option<IssueFixAutonomousPollResponse>, String> {
176181
let repository_path = required_repository_path(&request.repository_path)?;
182+
if !AutonomousIssueFix::is_bootstrapped(repository_path) {
183+
return Ok(None);
184+
}
177185
let loopx =
178186
LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?;
179187
let light = AutonomousIssueFix::new(loopx)
@@ -184,7 +192,7 @@ pub async fn issue_fix_autonomous_poll(
184192
format!("Failed to poll LoopX Issue-Fix state: {error}")
185193
})?;
186194
let host_loop = host_loop_state(&light.goal_id, repository_path).await;
187-
Ok(IssueFixAutonomousPollResponse { light, host_loop })
195+
Ok(Some(IssueFixAutonomousPollResponse { light, host_loop }))
188196
}
189197

190198
#[derive(Debug, Deserialize)]
@@ -306,7 +314,18 @@ pub async fn issue_fix_start_autonomous(
306314
})
307315
.collect::<Vec<_>>();
308316

309-
let plan = AutonomousIssueFix::new(loopx)
317+
let autonomous = AutonomousIssueFix::new(loopx);
318+
// First use on a fresh repository: create the LoopX goal and the host
319+
// agent lane before writing intake todos. No-op when already connected.
320+
autonomous
321+
.ensure_bootstrapped(repository_path)
322+
.await
323+
.map_err(|error| {
324+
error!("Failed to bootstrap LoopX for continuous Issue-Fix: {error}");
325+
format!("Failed to connect this repository to LoopX: {error}")
326+
})?;
327+
328+
let plan = autonomous
310329
.start(repository_path, request.repo.trim(), &selections)
311330
.await
312331
.map_err(|error| {

src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ use super::{LoopxIssueFix, LoopxIssueFixError};
1616
const ISSUE_INTAKE_ACTION: &str = "issue_fix_intake";
1717
const MAX_USER_REASON_CHARS: usize = 240;
1818

19+
/// Default objective for goals BitFun bootstraps itself: one sentence,
20+
/// repository-agnostic. Per-repo policy accrues in the goal's active state.
21+
const BOOTSTRAP_OBJECTIVE: &str = "Continuously repair repository issues explicitly \
22+
selected in BitFun, keep each issue isolated, validate every change, and surface \
23+
authority gates for human decisions.";
24+
25+
/// The registered agent id BitFun's host loop runs as.
26+
const BOOTSTRAP_AGENT_ID: &str = "bitfun-cron";
27+
1928
/// Host preamble prepended to LoopX's generated heartbeat contract.
2029
///
2130
/// LoopX owns every lifecycle rule (the `--compact` task body below it); this
@@ -230,6 +239,60 @@ impl AutonomousIssueFix {
230239
self.inspect_with_identity(repository_path, &identity).await
231240
}
232241

242+
/// True when the repository has a usable LoopX control plane (a registry
243+
/// with exactly one active goal and one registered agent).
244+
pub fn is_bootstrapped(repository_path: &Path) -> bool {
245+
read_identity(repository_path).is_ok()
246+
}
247+
248+
/// Bootstrap the repository into LoopX on first use: create the goal with
249+
/// BitFun's continuous-repair objective and register the host agent lane.
250+
///
251+
/// Idempotent at the call level — when a valid control plane already
252+
/// exists this is a no-op. The goal id derives from the directory name,
253+
/// mirroring `loopx bootstrap`'s own default.
254+
pub async fn ensure_bootstrapped(
255+
&self,
256+
repository_path: &Path,
257+
) -> Result<(), AutonomousIssueFixError> {
258+
if Self::is_bootstrapped(repository_path) {
259+
return Ok(());
260+
}
261+
self.loopx
262+
.json_in(
263+
repository_path,
264+
[
265+
"bootstrap",
266+
"--project",
267+
".",
268+
"--objective",
269+
BOOTSTRAP_OBJECTIVE,
270+
"--role",
271+
"controller",
272+
"--no-onboarding-scan",
273+
],
274+
)
275+
.await?;
276+
// The goal now exists but carries no registered agents yet, so the
277+
// full identity read (which requires exactly one agent) cannot be
278+
// used here — read the goal id alone.
279+
let goal_id = read_active_goal_id(repository_path)?;
280+
self.loopx
281+
.json_in(
282+
repository_path,
283+
[
284+
"register-agent",
285+
"--goal-id",
286+
goal_id.as_str(),
287+
"--agent-id",
288+
BOOTSTRAP_AGENT_ID,
289+
"--execute",
290+
],
291+
)
292+
.await?;
293+
Ok(())
294+
}
295+
233296
/// Cheap read-only projection for background polling: `todo list` only.
234297
///
235298
/// Unlike [`Self::inspect`], this never invokes `quota should-run`, which
@@ -523,35 +586,9 @@ fn scheduler_args(
523586

524587
fn read_identity(repository_path: &Path) -> Result<ControlIdentity, AutonomousIssueFixError> {
525588
let path = repository_path.join(".loopx").join("registry.json");
526-
let bytes = std::fs::read(&path).map_err(|source| AutonomousIssueFixError::RegistryRead {
527-
path: path.clone(),
528-
source,
529-
})?;
530-
let registry: Value =
531-
serde_json::from_slice(&bytes).map_err(|source| AutonomousIssueFixError::RegistryJson {
532-
path: path.clone(),
533-
source,
534-
})?;
535-
let active_goals = registry
536-
.get("goals")
537-
.and_then(Value::as_array)
538-
.into_iter()
539-
.flatten()
540-
.filter(|goal| goal.get("status").and_then(Value::as_str) == Some("active"))
541-
.collect::<Vec<_>>();
542-
if active_goals.len() != 1 {
543-
return Err(AutonomousIssueFixError::ActiveGoalCount(active_goals.len()));
544-
}
545-
let goal = active_goals[0];
546-
let goal_id = goal
547-
.get("id")
548-
.and_then(Value::as_str)
549-
.filter(|value| !value.trim().is_empty())
550-
.ok_or(AutonomousIssueFixError::MissingField {
551-
command: "registry",
552-
field: "goals[].id",
553-
})?
554-
.to_string();
589+
let registry = read_registry(&path)?;
590+
let goal = single_active_goal(&registry)?;
591+
let goal_id = goal_id_of(goal)?;
555592
let agents = goal
556593
.pointer("/coordination/registered_agents")
557594
.and_then(Value::as_array)
@@ -572,6 +609,50 @@ fn read_identity(repository_path: &Path) -> Result<ControlIdentity, AutonomousIs
572609
})
573610
}
574611

612+
/// The active goal id alone, for mid-bootstrap states where the agent lane is
613+
/// not registered yet.
614+
fn read_active_goal_id(repository_path: &Path) -> Result<String, AutonomousIssueFixError> {
615+
let path = repository_path.join(".loopx").join("registry.json");
616+
let registry = read_registry(&path)?;
617+
goal_id_of(single_active_goal(&registry)?)
618+
}
619+
620+
fn read_registry(path: &Path) -> Result<Value, AutonomousIssueFixError> {
621+
let bytes = std::fs::read(path).map_err(|source| AutonomousIssueFixError::RegistryRead {
622+
path: path.to_path_buf(),
623+
source,
624+
})?;
625+
serde_json::from_slice(&bytes).map_err(|source| AutonomousIssueFixError::RegistryJson {
626+
path: path.to_path_buf(),
627+
source,
628+
})
629+
}
630+
631+
fn single_active_goal(registry: &Value) -> Result<&Value, AutonomousIssueFixError> {
632+
let active_goals = registry
633+
.get("goals")
634+
.and_then(Value::as_array)
635+
.into_iter()
636+
.flatten()
637+
.filter(|goal| goal.get("status").and_then(Value::as_str) == Some("active"))
638+
.collect::<Vec<_>>();
639+
if active_goals.len() != 1 {
640+
return Err(AutonomousIssueFixError::ActiveGoalCount(active_goals.len()));
641+
}
642+
Ok(active_goals[0])
643+
}
644+
645+
fn goal_id_of(goal: &Value) -> Result<String, AutonomousIssueFixError> {
646+
goal.get("id")
647+
.and_then(Value::as_str)
648+
.filter(|value| !value.trim().is_empty())
649+
.map(str::to_string)
650+
.ok_or(AutonomousIssueFixError::MissingField {
651+
command: "registry",
652+
field: "goals[].id",
653+
})
654+
}
655+
575656
fn project_issue_todo(todo: &Value) -> Option<AutonomousIssueTodo> {
576657
if todo.get("role").and_then(Value::as_str) != Some("agent") {
577658
return None;

src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export const IssueFixPanel: React.FC<IssueFixPanelProps> = ({
118118
// Full readiness picture: loopx presence is `available`; the gh tiers gate
119119
// guidance banners (the agent's whole evidence/PR channel runs through gh).
120120
const [readiness, setReadiness] = useState<IssueFixAvailability | null>(null);
121+
// True once the control probe answered (even with "not connected"), so the
122+
// header can distinguish loading from pre-bootstrap.
123+
const [controlProbed, setControlProbed] = useState(false);
121124
const [loading, setLoading] = useState(false);
122125
const [running, setRunning] = useState(false);
123126
const [stopping, setStopping] = useState(false);
@@ -271,7 +274,10 @@ export const IssueFixPanel: React.FC<IssueFixPanelProps> = ({
271274
const ticket = takeControlTicket();
272275
try {
273276
const status = await issueFixAPI.getAutonomousStatus(workspacePath);
277+
// Null = repository not connected to LoopX yet (bootstraps on first
278+
// Start); distinguish it from "still loading" for the header text.
274279
applyControl(ticket, () => status);
280+
if (mountedRef.current) setControlProbed(true);
275281
} catch (error) {
276282
log.error('Failed to project continuous Issue-Fix state', { workspacePath, error });
277283
if (ticket >= appliedControlTicketRef.current && mountedRef.current) {
@@ -304,7 +310,7 @@ export const IssueFixPanel: React.FC<IssueFixPanelProps> = ({
304310
const ticket = takeControlTicket();
305311
try {
306312
const poll = await issueFixAPI.pollAutonomous(workspacePath);
307-
if (cancelled) return;
313+
if (cancelled || !poll) return;
308314
// A beat boundary shows up either as the active turn draining or as
309315
// the schedule advancing (which also catches beats shorter than one
310316
// poll interval); each boundary earns one full quota-backed refresh.
@@ -730,7 +736,9 @@ export const IssueFixPanel: React.FC<IssueFixPanelProps> = ({
730736
state: control.kernelState,
731737
queued: progress.queued,
732738
})
733-
: t('autonomous.loadingKernel')}
739+
: controlProbed
740+
? t('autonomous.notConnected')
741+
: t('autonomous.loadingKernel')}
734742
</span>
735743
{control && (control.userTodos ?? []).length > 0 ? (
736744
<button

src/web-ui/src/infrastructure/api/service-api/IssueFixAPI.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,14 @@ class IssueFixAPI {
105105
}
106106
}
107107

108-
async getAutonomousStatus(repositoryPath: string): Promise<IssueFixAutonomousStatusResponse> {
108+
/**
109+
* Full quota-backed projection, or null when the repository has never been
110+
* connected to LoopX (pre-bootstrap — a normal state, not an error; the
111+
* first Start bootstraps it).
112+
*/
113+
async getAutonomousStatus(
114+
repositoryPath: string,
115+
): Promise<IssueFixAutonomousStatusResponse | null> {
109116
const request = { repositoryPath };
110117
try {
111118
return await api.invoke('issue_fix_autonomous_status', { request });
@@ -118,9 +125,10 @@ class IssueFixAPI {
118125
/**
119126
* Background-poll variant of `getAutonomousStatus`: projects issue todos and
120127
* open gates from LoopX's todo list only, so an interval poll never invokes
121-
* `quota should-run` (which appends a LoopX rollout event per call).
128+
* `quota should-run` (which appends a LoopX rollout event per call). Null
129+
* before the repository is bootstrapped.
122130
*/
123-
async pollAutonomous(repositoryPath: string): Promise<IssueFixAutonomousPollResponse> {
131+
async pollAutonomous(repositoryPath: string): Promise<IssueFixAutonomousPollResponse | null> {
124132
const request = { repositoryPath };
125133
try {
126134
return await api.invoke('issue_fix_autonomous_poll', { request });

src/web-ui/src/locales/en-US/panels/issue-fix.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"closeIssue": "Resolved — approve closing Issue #{{issue}}",
6161
"closeIssueByPr": "Issue #{{issue}} resolved by PR #{{pr}} — approve closing it",
6262
"postComment": "Reply drafted for Issue #{{issue}} — approve posting it"
63-
}
63+
},
64+
"notConnected": "Not connected yet — select issues and press Start to begin"
6465
},
6566
"status": {
6667
"queued": "Queued",

src/web-ui/src/locales/zh-CN/panels/issue-fix.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"closeIssue": "已解决,请批准关闭 Issue #{{issue}}",
6161
"closeIssueByPr": "Issue #{{issue}} 已由 PR #{{pr}} 修复,请批准关闭",
6262
"postComment": "已为 Issue #{{issue}} 草拟回复,请批准发布"
63-
}
63+
},
64+
"notConnected": "尚未连接——勾选 Issue 后点击启动即可开始"
6465
},
6566
"status": {
6667
"queued": "排队中",

src/web-ui/src/locales/zh-TW/panels/issue-fix.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"closeIssue": "已解決,請批准關閉 Issue #{{issue}}",
6161
"closeIssueByPr": "Issue #{{issue}} 已由 PR #{{pr}} 修復,請批准關閉",
6262
"postComment": "已為 Issue #{{issue}} 草擬回覆,請批准發布"
63-
}
63+
},
64+
"notConnected": "尚未連接——勾選 Issue 後點擊啟動即可開始"
6465
},
6566
"status": {
6667
"queued": "排隊中",

0 commit comments

Comments
 (0)