jeo-code 0.7.33 → 0.7.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -1
- package/README.ja.md +4 -4
- package/README.ko.md +4 -4
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/package.json +2 -1
- package/src/agent/AGENTS.md +5 -2
- package/src/agent/approve-tool.ts +42 -0
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +47 -0
- package/src/agent/notify/daemon-control.ts +158 -0
- package/src/agent/notify/paths.ts +41 -0
- package/src/agent/notify/session-endpoint.ts +196 -0
- package/src/agent/notify/telegram-api.ts +83 -0
- package/src/agent/notify/telegram-daemon.ts +358 -0
- package/src/agent/plan.ts +27 -1
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +174 -93
- package/src/agent/tools.ts +19 -42
- package/src/ai/providers/anthropic.ts +2 -1
- package/src/ai/providers/antigravity.ts +3 -2
- package/src/ai/providers/gemini.ts +3 -2
- package/src/ai/providers/ollama.ts +2 -1
- package/src/ai/providers/openai-responses.ts +3 -2
- package/src/ai/providers/openai.ts +2 -1
- package/src/cli/runner.ts +29 -0
- package/src/commands/AGENTS.md +6 -2
- package/src/commands/approve.ts +63 -54
- package/src/commands/daemon.ts +45 -0
- package/src/commands/launch.ts +54 -4
- package/src/commands/notify.ts +109 -0
- package/src/commands/ralplan.ts +11 -1
- package/src/commands/team.ts +266 -79
- package/src/util/sanitize-json.ts +42 -0
package/src/commands/team.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import chalk from "chalk";
|
|
4
5
|
import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
|
|
@@ -8,24 +9,21 @@ import {
|
|
|
8
9
|
writeWorkflowState,
|
|
9
10
|
acquireWorkflowRunLock,
|
|
10
11
|
type WorkflowState,
|
|
12
|
+
type Config,
|
|
11
13
|
} from "../agent/state";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { gitDirtyCount } from "./launch/tmux";
|
|
14
|
+
import { runSubagentOnce, type SubagentRunResult } from "../agent/task-tool";
|
|
15
|
+
import { SubagentRegistry } from "../agent/subagent-registry";
|
|
16
|
+
import { gitDirtyCount, resolveWorktree } from "./launch/tmux";
|
|
16
17
|
import { readGlobalConfig } from "../agent/state";
|
|
17
18
|
import {
|
|
18
19
|
defaultSubagentRole,
|
|
19
20
|
getSubagentRole,
|
|
20
21
|
resolveSubagentModel,
|
|
21
22
|
resolveSubagentMaxSteps,
|
|
22
|
-
subagentSystemPrompt,
|
|
23
|
-
subagentToolset,
|
|
24
23
|
subagentRoleIds,
|
|
25
|
-
|
|
24
|
+
type SubagentRole,
|
|
26
25
|
} from "../agent/subagents";
|
|
27
|
-
import
|
|
28
|
-
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
26
|
+
import { loadProjectContext } from "../agent/context-files";
|
|
29
27
|
import { categoryBadge } from "../tui/components/category-index";
|
|
30
28
|
|
|
31
29
|
export type RalphStreamKind = "step" | "complete" | "error" | "warn";
|
|
@@ -343,12 +341,65 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
343
341
|
return { ok: false, reason: "aborted" };
|
|
344
342
|
}
|
|
345
343
|
|
|
346
|
-
const currentTask = teamState.pending_tasks[0];
|
|
347
|
-
log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
|
|
348
344
|
const activeIndex = activeStepIndex(tasks.length, teamState.pending_tasks);
|
|
349
345
|
// Guide printed once above; per-task lines convey progress. No per-iteration
|
|
350
346
|
// reprint — it was O(N²) lines, climbing the embedded-terminal renderer's CPU.
|
|
351
347
|
|
|
348
|
+
// Look ahead from the current position: if this step's `parallel_group`
|
|
349
|
+
// extends into one or more of the NEXT pending steps (the group's full
|
|
350
|
+
// contiguous run is, by construction, always still all-pending here —
|
|
351
|
+
// `activeIndex` is derived from how many tasks remain), dispatch the WHOLE
|
|
352
|
+
// group concurrently. A group of size 1 (no group, or a lone member) falls
|
|
353
|
+
// straight through to the existing serial path, unchanged.
|
|
354
|
+
const groupVal = parsed.data.steps[activeIndex]?.parallel_group?.trim() || undefined;
|
|
355
|
+
const groupIndices = [activeIndex];
|
|
356
|
+
if (groupVal) {
|
|
357
|
+
let i = activeIndex + 1;
|
|
358
|
+
while (i < tasks.length && (parsed.data.steps[i]?.parallel_group?.trim() || undefined) === groupVal) {
|
|
359
|
+
groupIndices.push(i);
|
|
360
|
+
i++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (groupIndices.length > 1) {
|
|
365
|
+
const groupSteps = groupIndices.map(i => ({ index: i, name: tasks[i]!, roleId: roleByIndex[i] }));
|
|
366
|
+
log(`\n${categoryBadge("progress")} Current parallel group "${groupVal}": ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
|
|
367
|
+
if (opts.onProgress) {
|
|
368
|
+
opts.onProgress({ skill: "team", phase: "executing", detail: `Current parallel group: ${groupSteps.map(s => s.name).join(", ")}` });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const groupResult = await runParallelGroup(groupSteps, {
|
|
372
|
+
cwd,
|
|
373
|
+
teamCfg,
|
|
374
|
+
tasks,
|
|
375
|
+
completed: teamState.completed_tasks ?? [],
|
|
376
|
+
strictMutations: opts.strictMutations ?? false,
|
|
377
|
+
log,
|
|
378
|
+
slug: planState.slug ?? teamState.slug ?? "team",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (opts.signal?.aborted) {
|
|
382
|
+
return { ok: false, reason: "aborted" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (groupResult.ok) {
|
|
386
|
+
teamState.completed_tasks = [...(teamState.completed_tasks ?? []), ...groupSteps.map(s => s.name)];
|
|
387
|
+
teamState.pending_tasks = teamState.pending_tasks.slice(groupSteps.length);
|
|
388
|
+
await writeWorkflowState("team", teamState, cwd);
|
|
389
|
+
log(`${categoryBadge("done")} Completed parallel group: ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
|
|
390
|
+
} else {
|
|
391
|
+
teamState.current_phase = "failed";
|
|
392
|
+
teamState.failed_task = groupResult.failedTaskName;
|
|
393
|
+
await writeWorkflowState("team", teamState, cwd);
|
|
394
|
+
log(`${categoryBadge("error")} Failed on parallel group step: "${groupResult.failedTaskName}". Halting execution.`);
|
|
395
|
+
return { ok: false, reason: `Failed on task: "${groupResult.failedTaskName}"` };
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const currentTask = teamState.pending_tasks[0];
|
|
401
|
+
log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
|
|
402
|
+
|
|
352
403
|
if (opts.onProgress) {
|
|
353
404
|
opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
|
|
354
405
|
}
|
|
@@ -417,89 +468,68 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
417
468
|
const maxSteps = resolveSubagentMaxSteps(role.id, config);
|
|
418
469
|
log(` └─ Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps`);
|
|
419
470
|
|
|
420
|
-
const contextTokens = catalogMetadata(model)?.contextTokens;
|
|
421
|
-
|
|
422
471
|
const projectContext = await loadProjectContext(ctx.cwd);
|
|
423
|
-
const history: Message[] = [
|
|
424
|
-
{ role: "system", content: withProjectContext(subagentSystemPrompt(role), projectContext) },
|
|
425
|
-
{ role: "user", content: buildRalphSubagentPrompt(ctx) },
|
|
426
|
-
];
|
|
427
472
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// orchestrator owns retries, so the gjc step-extension flow is disabled here.
|
|
445
|
-
budget: { maxExtensions: 0 },
|
|
446
|
-
tools: subagentToolset(role),
|
|
447
|
-
events: {
|
|
448
|
-
onAssistant: (_raw, invocation) => {
|
|
449
|
-
if (!invocation) {
|
|
450
|
-
log(formatRalphStreamEvent("error", "invalid tool-call json; retrying", renderOpts));
|
|
451
|
-
} else if (invocation.tool !== "done") {
|
|
452
|
-
log(formatRalphStreamEvent("step", `tool ${invocation.tool} requested`, renderOpts));
|
|
453
|
-
}
|
|
454
|
-
},
|
|
455
|
-
onStep: async step => {
|
|
456
|
-
log(formatRalphStreamEvent("step", `${role.title} thinking ${step}/${maxSteps}`, renderOpts));
|
|
457
|
-
try {
|
|
458
|
-
await maybeCompact(history, { model, contextTokens });
|
|
459
|
-
} catch (err) {
|
|
460
|
-
// LLM summary failure does not halt team
|
|
461
|
-
}
|
|
462
|
-
},
|
|
463
|
-
onToolResult: (tool, ok) => {
|
|
464
|
-
if (ok) {
|
|
465
|
-
if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
|
|
466
|
-
else if (tool === "bash") bashRuns++;
|
|
467
|
-
}
|
|
468
|
-
log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
|
|
469
|
-
},
|
|
470
|
-
onNotice: msg => log(formatRalphStreamEvent("step", msg, renderOpts)),
|
|
473
|
+
// `jeo team` now runs every step through the SAME execution core as the `task`/
|
|
474
|
+
// `subagent` tools (`runSubagentOnce`) — one way a jeo-code subagent runs,
|
|
475
|
+
// whether launched interactively, in a fan-out batch, detached, or as a team
|
|
476
|
+
// plan step. This gets team plan steps the same fenced-report protection
|
|
477
|
+
// (prompt-injection-safe) and done-reason contract validation the interactive
|
|
478
|
+
// tools already had, instead of a second hand-rolled copy of that logic.
|
|
479
|
+
const result = await runSubagentOnce(role, buildRalphSubagentPrompt(ctx), "", ctx.cwd, {
|
|
480
|
+
config,
|
|
481
|
+
projectContext,
|
|
482
|
+
onEvent: ev => {
|
|
483
|
+
if (ev.kind === "step") {
|
|
484
|
+
log(formatRalphStreamEvent("step", ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`, renderOpts));
|
|
485
|
+
} else if (ev.kind === "tool") {
|
|
486
|
+
const suffix = ev.summary ? ` — ${ev.summary}` : "";
|
|
487
|
+
log(formatRalphStreamEvent(ev.success ? "complete" : "error", `tool ${ev.detail}${suffix}`, renderOpts));
|
|
488
|
+
}
|
|
471
489
|
},
|
|
472
490
|
});
|
|
473
491
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
log(formatRalphStreamEvent("error", reason, renderOpts));
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
492
|
+
return evaluateSubagentResult(result, role, ctx.strictMutations ?? false, log, renderOpts);
|
|
493
|
+
}
|
|
479
494
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Shared pass/fail verdict for ONE subagent's `SubagentRunResult`: contract
|
|
497
|
+
* failure, role-gate rejection (architect BLOCK / critic REQUEST CHANGES), and
|
|
498
|
+
* `--strict-mutations` hard-fail. Used by BOTH the serial path
|
|
499
|
+
* (`executeTaskWithAgent`) and the parallel-group path (`runParallelGroup`) so
|
|
500
|
+
* the two never diverge on what counts as a passing step.
|
|
501
|
+
*/
|
|
502
|
+
function evaluateSubagentResult(
|
|
503
|
+
result: SubagentRunResult,
|
|
504
|
+
role: SubagentRole,
|
|
505
|
+
strictMutations: boolean,
|
|
506
|
+
log: (line: string) => void,
|
|
507
|
+
renderOpts: RalphRenderOptions,
|
|
508
|
+
): boolean {
|
|
509
|
+
if (!result.success) {
|
|
510
|
+
log(formatRalphStreamEvent("error", result.error || result.output, renderOpts));
|
|
483
511
|
return false;
|
|
484
512
|
}
|
|
485
513
|
|
|
486
|
-
|
|
514
|
+
// Role gate verdict (architect BLOCK / critic REQUEST CHANGES) is parsed from the
|
|
515
|
+
// subagent's RAW done reason, not the fenced/wrapped report text around it.
|
|
516
|
+
const gate = parseRoleGateVerdict(role.id, result.doneReason);
|
|
487
517
|
if (!gate.ok) {
|
|
488
518
|
log(formatRalphStreamEvent("error", gate.message ?? `${role.title} blocked execution`, renderOpts));
|
|
489
519
|
return false;
|
|
490
520
|
}
|
|
491
521
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const msg = bashRuns === 0
|
|
522
|
+
// Round-8: a mutating role finished without a successful file mutation — the
|
|
523
|
+
// task may be legitimately read-only, but its "Changed Files:" claim is
|
|
524
|
+
// unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
|
|
525
|
+
if (!role.readOnly && result.fileMutations === 0) {
|
|
526
|
+
const msg = result.bashRuns === 0
|
|
497
527
|
? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
|
|
498
528
|
: `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
|
|
499
529
|
// Round-11: under --strict-mutations, a mutating role that took NO action at
|
|
500
530
|
// all (no write/edit/bash) is a hard failure — an empty run must not pass as
|
|
501
531
|
// a completed task. bash-only stays advisory to avoid penalizing shell edits.
|
|
502
|
-
const hardFail =
|
|
532
|
+
const hardFail = strictMutations && result.bashRuns === 0;
|
|
503
533
|
// Round-12: separate the tones so a passing advisory run doesn't masquerade
|
|
504
534
|
// as a stream:error — only a real hard-fail is red; an advisory note is warn.
|
|
505
535
|
log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
|
|
@@ -507,12 +537,169 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
507
537
|
return false;
|
|
508
538
|
}
|
|
509
539
|
}
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
//
|
|
540
|
+
|
|
541
|
+
// Surface the subagent's full report (header + step trace + fenced reason +
|
|
542
|
+
// mutation audit) — not just a "finished" status.
|
|
513
543
|
log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
|
|
514
544
|
log(`\n${role.title} report:`);
|
|
515
|
-
log(
|
|
545
|
+
log(result.output); // log() already splits multi-line strings across the io.output sink
|
|
516
546
|
|
|
517
547
|
return true;
|
|
518
548
|
}
|
|
549
|
+
|
|
550
|
+
/** One step of a dispatched parallel group, resolved to its plan position + role. */
|
|
551
|
+
interface ParallelGroupStep {
|
|
552
|
+
index: number;
|
|
553
|
+
name: string;
|
|
554
|
+
roleId?: string;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Per-step worktree + branch resolved for a dispatched parallel group. */
|
|
558
|
+
interface ParallelWorktree {
|
|
559
|
+
path: string;
|
|
560
|
+
branch: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function runGitSync(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
564
|
+
const res = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
565
|
+
return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Execute a CONTIGUOUS run of `parallel_group`-marked plan steps concurrently,
|
|
570
|
+
* each in its own git worktree via `resolveWorktree` + the SAME
|
|
571
|
+
* `SubagentRegistry`/`runSubagentOnce` core the serial path and the `task`/
|
|
572
|
+
* `subagent` tools use. On full success, each worker's committed branch is
|
|
573
|
+
* merged back into `cwd`'s current branch IN ARRAY ORDER; any failure or merge
|
|
574
|
+
* conflict stops processing immediately and leaves the remaining
|
|
575
|
+
* worktrees/branches in place for manual inspection — nothing is ever silently
|
|
576
|
+
* resolved or discarded.
|
|
577
|
+
*/
|
|
578
|
+
async function runParallelGroup(
|
|
579
|
+
group: ParallelGroupStep[],
|
|
580
|
+
ctx: {
|
|
581
|
+
cwd: string;
|
|
582
|
+
teamCfg: Config;
|
|
583
|
+
tasks: string[];
|
|
584
|
+
completed: readonly string[];
|
|
585
|
+
strictMutations: boolean;
|
|
586
|
+
log: (line: string) => void;
|
|
587
|
+
slug: string;
|
|
588
|
+
},
|
|
589
|
+
): Promise<{ ok: true } | { ok: false; failedTaskName: string }> {
|
|
590
|
+
const { cwd, teamCfg, tasks, completed, strictMutations, log, slug } = ctx;
|
|
591
|
+
const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
|
|
592
|
+
|
|
593
|
+
const registry = new SubagentRegistry();
|
|
594
|
+
const resultsByIndex = new Map<number, SubagentRunResult>();
|
|
595
|
+
const idByIndex = new Map<number, string>();
|
|
596
|
+
const roleByStepIndex = new Map<number, SubagentRole>();
|
|
597
|
+
const worktreeByIndex = new Map<number, ParallelWorktree>();
|
|
598
|
+
|
|
599
|
+
log(`\n${categoryBadge("progress")} Dispatching parallel group of ${group.length} steps concurrently: ${group.map(s => `"${s.name}"`).join(", ")}`);
|
|
600
|
+
|
|
601
|
+
for (const step of group) {
|
|
602
|
+
const worktreePath = path.join(cwd, ".jeo", "team-worktrees", `${slug}-${step.index}`);
|
|
603
|
+
const resolvedPath = resolveWorktree(cwd, worktreePath);
|
|
604
|
+
const branchRes = runGitSync(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
605
|
+
const branch = branchRes.stdout.trim();
|
|
606
|
+
worktreeByIndex.set(step.index, { path: resolvedPath, branch });
|
|
607
|
+
|
|
608
|
+
const role = getSubagentRole(step.roleId, teamCfg) ?? defaultSubagentRole();
|
|
609
|
+
roleByStepIndex.set(step.index, role);
|
|
610
|
+
const model = resolveSubagentModel(role.id, teamCfg);
|
|
611
|
+
const maxSteps = resolveSubagentMaxSteps(role.id, teamCfg);
|
|
612
|
+
log(` └─ [${step.name}] Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps · worktree ${resolvedPath} (branch ${branch})`);
|
|
613
|
+
|
|
614
|
+
// Loaded PER WORKTREE (its own file tree) — never shared across concurrent workers.
|
|
615
|
+
const projectContext = await loadProjectContext(resolvedPath);
|
|
616
|
+
const promptCtx: RalphSubagentPromptContext = { task: step.name, tasks, activeIndex: step.index, completed };
|
|
617
|
+
const taskText = buildRalphSubagentPrompt(promptCtx);
|
|
618
|
+
|
|
619
|
+
const record = registry.launch(role.id, step.name, async (signal) => {
|
|
620
|
+
const result = await runSubagentOnce(role, taskText, "", resolvedPath, {
|
|
621
|
+
config: teamCfg,
|
|
622
|
+
signal,
|
|
623
|
+
projectContext,
|
|
624
|
+
onEvent: ev => {
|
|
625
|
+
if (ev.kind === "step") {
|
|
626
|
+
log(formatRalphStreamEvent("step", `[${step.name}] ${ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`}`, renderOpts));
|
|
627
|
+
} else if (ev.kind === "tool") {
|
|
628
|
+
const suffix = ev.summary ? ` — ${ev.summary}` : "";
|
|
629
|
+
log(formatRalphStreamEvent(ev.success ? "complete" : "error", `[${step.name}] tool ${ev.detail}${suffix}`, renderOpts));
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
// Capture the FULL SubagentRunResult (doneReason/fileMutations/etc.) before
|
|
634
|
+
// collapsing it to the plain ToolResult shape SubagentRegistry stores.
|
|
635
|
+
resultsByIndex.set(step.index, result);
|
|
636
|
+
return { success: result.success, output: result.output, error: result.error };
|
|
637
|
+
});
|
|
638
|
+
idByIndex.set(step.index, record.id);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// No timeout — team runs can take as long as needed.
|
|
642
|
+
await registry.awaitIds([...idByIndex.values()]);
|
|
643
|
+
|
|
644
|
+
// Evaluate + commit each successful worktree, IN ARRAY ORDER (not completion
|
|
645
|
+
// order). Any failure stops the whole group immediately: no merges happen,
|
|
646
|
+
// and every worktree/branch is left exactly as it is for inspection.
|
|
647
|
+
for (const step of group) {
|
|
648
|
+
const result = resultsByIndex.get(step.index);
|
|
649
|
+
const role = roleByStepIndex.get(step.index)!;
|
|
650
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
651
|
+
if (!result) {
|
|
652
|
+
log(formatRalphStreamEvent("error", `[${step.name}] subagent produced no result`, renderOpts));
|
|
653
|
+
return { ok: false, failedTaskName: step.name };
|
|
654
|
+
}
|
|
655
|
+
const success = evaluateSubagentResult(result, role, strictMutations, log, renderOpts);
|
|
656
|
+
if (!success) {
|
|
657
|
+
log(`${categoryBadge("error")} Parallel group step "${step.name}" failed — its worktree (${wt.path}) and the OTHER group worktrees are left in place for inspection; nothing was merged.`);
|
|
658
|
+
return { ok: false, failedTaskName: step.name };
|
|
659
|
+
}
|
|
660
|
+
// An empty diff (read-only role, or a no-op) needs no commit — skip it for
|
|
661
|
+
// the merge step below without treating it as an error.
|
|
662
|
+
if (gitDirtyCount(wt.path)) {
|
|
663
|
+
const add = runGitSync(wt.path, ["add", "-A"]);
|
|
664
|
+
if (!add.ok) {
|
|
665
|
+
log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
|
|
666
|
+
return { ok: false, failedTaskName: step.name };
|
|
667
|
+
}
|
|
668
|
+
const commit = runGitSync(wt.path, ["commit", "-m", step.name]);
|
|
669
|
+
if (!commit.ok) {
|
|
670
|
+
log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
|
|
671
|
+
return { ok: false, failedTaskName: step.name };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// All group steps succeeded — merge each worker branch back, IN ARRAY ORDER.
|
|
677
|
+
// A conflict aborts the merge immediately (never auto-resolved) and stops the
|
|
678
|
+
// rest of the group's merges.
|
|
679
|
+
for (const step of group) {
|
|
680
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
681
|
+
const merge = runGitSync(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
|
|
682
|
+
if (!merge.ok) {
|
|
683
|
+
runGitSync(cwd, ["merge", "--abort"]);
|
|
684
|
+
log(
|
|
685
|
+
`${categoryBadge("error")} Merge conflict merging step "${step.name}"'s branch "${wt.branch}" into the current branch — ` +
|
|
686
|
+
`the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
|
|
687
|
+
`cd ${wt.path} to inspect the change, then run 'git merge ${wt.branch}' yourself from ${cwd}.\n${(merge.stderr + merge.stdout).trim()}`,
|
|
688
|
+
);
|
|
689
|
+
return { ok: false, failedTaskName: step.name };
|
|
690
|
+
}
|
|
691
|
+
log(`${categoryBadge("done")} Merged step "${step.name}" (branch ${wt.branch}) into the current branch.`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Fully successful group — clean up worktrees best-effort (a removal failure,
|
|
695
|
+
// e.g. stray untracked files, is a warning, never a hard failure of the run).
|
|
696
|
+
for (const step of group) {
|
|
697
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
698
|
+
const removal = runGitSync(cwd, ["worktree", "remove", wt.path]);
|
|
699
|
+
if (!removal.ok) {
|
|
700
|
+
log(`[WARN] Could not remove worktree ${wt.path} for step "${step.name}": ${removal.stderr.trim()}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return { ok: true };
|
|
705
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lone-surrogate sanitization for provider request bodies (gjc 0.8.1 parity).
|
|
3
|
+
*
|
|
4
|
+
* Tool-call arguments, tool-result output, and message content are embedded raw into
|
|
5
|
+
* provider request bodies before `JSON.stringify`. A truncated multi-byte emoji (or
|
|
6
|
+
* model-echoed text) can leave an unpaired UTF-16 surrogate in a string; `JSON.stringify`
|
|
7
|
+
* happily emits it as a lone `\ud8xx`/`\udcxx` escape, which strict provider JSON parsers
|
|
8
|
+
* reject (e.g. Anthropic 400 "no low surrogate in string"). `sanitizeJsonStrings` walks an
|
|
9
|
+
* arbitrary JS value and replaces every string (both object keys and values) with its
|
|
10
|
+
* `.toWellFormed()` form, which turns lone surrogates into U+FFFD without touching valid
|
|
11
|
+
* surrogate pairs (real emoji, etc.).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Recursively sanitize every string value and object key in `value` for well-formed UTF-16.
|
|
15
|
+
* Cycle-safe via a `WeakMap` of already-sanitized objects/arrays. */
|
|
16
|
+
export function sanitizeJsonStrings<T>(value: T): T {
|
|
17
|
+
return sanitizeJsonStringsInner(value, new WeakMap<object, unknown>()) as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sanitizeJsonStringsInner(value: unknown, seen: WeakMap<object, unknown>): unknown {
|
|
21
|
+
if (typeof value === "string") return value.toWellFormed();
|
|
22
|
+
if (!value || typeof value !== "object") return value;
|
|
23
|
+
|
|
24
|
+
const cached = seen.get(value);
|
|
25
|
+
if (cached !== undefined) return cached;
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
const sanitized: unknown[] = [];
|
|
29
|
+
seen.set(value, sanitized);
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
sanitized.push(sanitizeJsonStringsInner(item, seen));
|
|
32
|
+
}
|
|
33
|
+
return sanitized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sanitized: Record<string, unknown> = {};
|
|
37
|
+
seen.set(value, sanitized);
|
|
38
|
+
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
|
39
|
+
sanitized[key.toWellFormed()] = sanitizeJsonStringsInner(nestedValue, seen);
|
|
40
|
+
}
|
|
41
|
+
return sanitized;
|
|
42
|
+
}
|