Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
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
91 changes: 66 additions & 25 deletions apps/desktop/src/server/operations/symphony-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface LoopRequestBody {
artifacts: LoopArtifact[];
repo?: LoopRepo;
committer?: LoopCommitter;
parentLoopId?: string;
parentBranchName?: string;
parentSessionId?: string;
prompt?: string;
Expand Down Expand Up @@ -225,17 +226,24 @@ function resolveLoopWorktreeDir(
}

/**
* Pick the stable ID for worktree/branch naming.
* Uses loopId (matching ECS harness branch/run-dir naming).
* Slugified the same way as the harness: lowercase, non-alnum to dashes, max 50 chars.
* Slugify a loop ID for worktree/branch naming.
* Matches ECS harness convention: lowercase, non-alnum to dashes, max 50 chars.
*/
function pickStableId(body: LoopRequestBody): string {
return body.loopId
function slugifyLoopId(loopId: string): string {
return loopId
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.slice(0, 50);
}

/**
* Pick the stable ID for worktree/branch naming.
* Uses loopId (matching ECS harness branch/run-dir naming).
*/
function pickStableId(body: LoopRequestBody): string {
return slugifyLoopId(body.loopId);
}

// ---------------------------------------------------------------------------
// API communication (events + artifact upload)
// ---------------------------------------------------------------------------
Expand All @@ -247,7 +255,12 @@ async function postLoopEvent(
eventBody: Record<string, unknown>
): Promise<void> {
const url = `${apiBaseUrl}/loops/${loopId}/events`;
loopLog(loopId, `POST event: ${eventBody.type}`, url);
// Auto-inject timestamp on every event (matches ECS harness reportEvent())
const payload: Record<string, unknown> = {
...eventBody,
timestamp: eventBody.timestamp ?? new Date().toISOString(),
};
loopLog(loopId, `POST event: ${payload.type}`, url);
try {
const resp = await fetch(url, {
method: "POST",
Expand All @@ -256,7 +269,7 @@ async function postLoopEvent(
"Content-Type": "application/json",
"x-loop-event-nonce": crypto.randomUUID(),
},
body: JSON.stringify(eventBody),
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
Expand Down Expand Up @@ -771,12 +784,30 @@ async function handleProcessCompletion(

if (command === "EXECUTE" && artifacts.executionResult) {
const execResult = artifacts.executionResult as Record<string, unknown>;
result.pr_url = execResult.pr_url;
result.pr_number = execResult.pr_number;
result.branch_name = execResult.branch_name;
result.prUrl = execResult.pr_url;
result.prNumber = execResult.pr_number;
result.branchName = execResult.branch_name;
result.has_changes = execResult.has_changes ?? false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Correctness

[P2] has_changes not renamed to hasChanges while sibling fields were renamed to camelCase

Recommendation: Rename result.has_changes to result.hasChanges on line 790 to match the camelCase convention applied to the other three fields in the same block.

result.prUrl = execResult.pr_url;
    result.prNumber = execResult.pr_number;
    result.branchName = execResult.branch_name;
    result.has_changes = execResult.has_changes ?? false;

}

// Include worktree branch name for all commands that use a worktree.
// The server persists this on the loop record for display/debugging.
if (worktreeDir && !result.branchName) {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreeDir,
encoding: "utf-8",
stdio: "pipe",
timeout: 5_000,
}).trim();
if (branch) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Correctness

[P2] git rev-parse may return literal "HEAD" in detached-HEAD state, stored as branchName

Recommendation: Add a check for the detached HEAD sentinel: if (branch && branch !== 'HEAD') { result.branchName = branch; }

if (branch) {
        result.branchName = branch;
      }

result.branchName = branch;
}
} catch {
// Non-critical — worktree may already be cleaned up
}
}

// sessionId inside result (matches harness)
if (metadata.sessionId) {
result.sessionId = metadata.sessionId;
Expand Down Expand Up @@ -911,26 +942,36 @@ async function handleLoopRequest(
await fs.mkdir(claudeWorkDir, { recursive: true });
await writeArtifactsForPlan(claudeWorkDir, body.artifacts, body.prompt);
} else if (body.command === "EXECUTE" || body.command === "REQUEST_CHANGES") {
// EXECUTE/REQUEST_CHANGES: reuse parent worktree if possible
if (body.parentBranchName) {
worktreeDir = findWorktreeForBranch(
expandedRepoPath,
body.parentBranchName
);
// EXECUTE/REQUEST_CHANGES: reuse parent's worktree.
// Derive the parent's worktree path from parentLoopId (deterministic naming),
// falling back to parentBranchName for backwards compat.
const parentStableId = body.parentLoopId ? slugifyLoopId(body.parentLoopId) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Correctness

[P2] parentLoopId is not validated as a UUID, unlike loopId

Recommendation: Add UUID format validation for parentLoopId when it is provided, consistent with the existing loopId check: if (body.parentLoopId && !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(body.parentLoopId)) { json(context, 400, { error: 'parentLoopId must be a valid UUID' }); return; }

const parentStableId = body.parentLoopId ? slugifyLoopId(body.parentLoopId) : null;

if (parentStableId) {
const parentBranch = `symphony/loop-${parentStableId}`;
worktreeDir = findWorktreeForBranch(expandedRepoPath, parentBranch);
if (worktreeDir) {
try {
assertPathAllowed(worktreeDir, allowedDirs);
} catch (e) {
if (e instanceof DirectoryNotAllowedError) {
json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` });
return;
}
throw e;
loopLog(body.loopId, `Reusing parent worktree via parentLoopId: ${worktreeDir}`);
}
}
if (!worktreeDir && body.parentBranchName) {
worktreeDir = findWorktreeForBranch(expandedRepoPath, body.parentBranchName);
if (worktreeDir) {
loopLog(body.loopId, `Reusing parent worktree via parentBranchName: ${worktreeDir}`);
}
}
if (worktreeDir) {
try {
assertPathAllowed(worktreeDir, allowedDirs);
} catch (e) {
if (e instanceof DirectoryNotAllowedError) {
json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` });
return;
}
throw e;
}
}
if (!worktreeDir) {
// Create new worktree
// No parent worktree found — create a new one
const loopBranch = `symphony/loop-${pickStableId(body)}`;
worktreeDir = resolveLoopWorktreeDir(expandedRepoPath, pickStableId(body));
await ensureWorktree(
Expand Down
Loading