Skip to content
Merged
Show file tree
Hide file tree
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
94 changes: 92 additions & 2 deletions electron/runner-orch-notices.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,86 @@

const services = require("./services.js");
const { noticePrompt } = require("./runnerHelpers.js");
const { gitTry } = require("./worktrees.js");
const { normalizeCommand } = require("./verify.js");
const { readAnnotation } = require("./reviewItinerary.js");

/** Body cap for the structured worker result in a wake notice (#1427). */
const WORKER_RESULT_MAX = 2000;
const WORKER_RESULT_END = "[/worker result]";

/**
* Structured result for a finished worker (#1427), built from data Solenta
* already stores: diffstat vs the lead snapshot, the verify verdict, the
* worker's ruled-out hypotheses, and its review-itinerary risks. The body
* is capped at WORKER_RESULT_MAX; "" when there is nothing to report.
* src/workerResult.ts parses the `[worker result <id>]` fence.
* @param {object} thread - the worker
* @returns {string}
*/
function workerResultBlock(thread) {
const lines = [];
const command = normalizeCommand(thread.verifyCommand);
const v = thread.verify;
// The gate runs after every turn while a command is set, so thread.verify
// is the last turn's verdict. A cleared command leaves a stale one: skip.
if (command && v) {
const how = v.ok
? `passed in ${Math.round((Number(v.durationMs) || 0) / 1000)}s`
: v.timedOut
? "failed (timed out)"
: `failed (exit ${v.exitCode == null ? "?" : v.exitCode})`;
lines.push(`Verify: ${how}: ${v.command || command}`);
}
const base =
typeof thread.leadSnapshotSha === "string" ? thread.leadSnapshotSha.trim() : "";
if (base && thread.worktreePath) {
// ponytail: sync, but commit-to-commit (no worktree scan) and once per
// finished worker. Keeps notice order and stop-is-sacred (#32) intact.
const tip = thread.branch ? String(thread.branch) : "HEAD";
const res = gitTry(
thread.worktreePath,
["diff", "--stat=100", "--stat-count=40", "--summary", `${base}..${tip}`],
{ raw: true, timeout: 5000 },
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for the worker checkpoint before diffing commits

For the common worker without a verify command, afterSuccessfulTurn starts maybeCreateCheckpoint without awaiting it and immediately calls finishSuccessfulTurn (electron/runner-verify-gate.js:127-133). This commit-to-commit diff therefore runs while the worker's just-finished changes are still uncommitted—indeed, the synchronous gitTry prevents the asynchronous checkpoint from completing first—so the permanent result card can report Changes ...: none or omit files such as a symlink replacement. Await the checkpoint before queuing the notice, or include the working tree in the summary.

Useful? React with 👍 / 👎.

);
const out = res.ok ? res.stdout.split("\n").filter((l) => l.trim()) : [];
if (out.length) {
// A symlink replacing a directory (core/dist, #1427) must survive the cap.
const links = out.filter((l) => / mode 120000 /.test(l));
lines.push(`Changes since lead snapshot ${base.slice(0, 7)}:`);
lines.push(...links, ...out.filter((l) => !links.includes(l)));
} else if (res.ok) {
lines.push(`Changes since lead snapshot ${base.slice(0, 7)}: none`);
}
}
const ruled = (Array.isArray(thread.hypotheses) ? thread.hypotheses : [])
.filter((h) => h && h.status === "invalidated" && String(h.claim || "").trim())
.map((h) => {
const reason = String(h.reason || "").trim();
return `- ${String(h.claim).trim()}${reason ? ` — ${reason}` : ""}`;
});
if (ruled.length) lines.push("Ruled out:", ...ruled);
const note = thread.worktreePath ? readAnnotation(thread.worktreePath, thread.id) : null;
const open = [];
if (note) {
for (const c of Array.isArray(note.chunks) ? note.chunks : []) {
for (const r of c && Array.isArray(c.risks) ? c.risks : []) {
if (String(r || "").trim()) open.push(`- ${c.area ? `${c.area}: ` : ""}${String(r).trim()}`);
}
}
for (const r of Array.isArray(note.risks) ? note.risks : []) {
if (String(r || "").trim()) open.push(`- ${String(r).trim()}`);
}
}
if (open.length) lines.push("Open review items:", ...open);
if (!lines.length) return "";
let body = lines.join("\n");
if (body.length > WORKER_RESULT_MAX) {
const cut = body.slice(0, WORKER_RESULT_MAX);
body = `${cut.slice(0, Math.max(cut.lastIndexOf("\n"), 0))}\n… (truncated)`;
}
return `\n[worker result ${thread.id}]\n${body}\n${WORKER_RESULT_END}`;
}

// Issue #213: keep only the newest N worker threads per orchestrator so
// fan-out cannot grow the store without bound. No settings knob; skipped
Expand Down Expand Up @@ -218,7 +298,8 @@ function createOrchNotices(ctx) {
parentId,
`Worker thread ${threadId}${title} finished with status ${status}.` +
(line ? ` Last reply: ${line}` : "") +
merge,
merge +
safeWorkerResultBlock(thread),
);
flushOrchNotices(parentId);
}
Expand Down Expand Up @@ -487,4 +568,13 @@ function createOrchNotices(ctx) {
};
}

module.exports = { createOrchNotices };
/** queueOrchNotice never throws; a broken block just drops out. */
function safeWorkerResultBlock(thread) {
try {
return workerResultBlock(thread);
} catch {
return "";
}
}

module.exports = { createOrchNotices, workerResultBlock, WORKER_RESULT_MAX };
103 changes: 103 additions & 0 deletions electron/test/worker-result-block.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
workerResultBlock,
WORKER_RESULT_MAX,
} = require("../runner-orch-notices.js");
const { rmTree } = require("./support/rmTree.js");

function git(cwd, args) {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}

describe("workerResultBlock (#1427)", () => {
let repo;
let base;

beforeEach(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), "worker-result-"));
git(repo, ["init", "-q", "-b", "main"]);
git(repo, ["config", "user.email", "t@t"]);
git(repo, ["config", "user.name", "t"]);
fs.mkdirSync(path.join(repo, "core/dist"), { recursive: true });
fs.writeFileSync(path.join(repo, "core/dist/index.js"), "x\n");
git(repo, ["add", "-A"]);
git(repo, ["commit", "-qm", "base"]);
base = git(repo, ["rev-parse", "HEAD"]);
git(repo, ["checkout", "-qb", "worker"]);
});

afterEach(() => rmTree(repo));

function worker(extra) {
return {
id: "w1",
worktreePath: repo,
branch: "worker",
leadSnapshotSha: base,
...extra,
};
}

it("includes the create mode 120000 line for a branch that adds a symlink", () => {
git(repo, ["rm", "-rq", "core/dist"]);
fs.mkdirSync(path.join(repo, "core"), { recursive: true });
fs.symlinkSync("../../elsewhere/dist", path.join(repo, "core/dist"));
git(repo, ["add", "-A"]);
git(repo, ["commit", "-qm", "symlink"]);
const block = workerResultBlock(
worker({
verifyCommand: "npm test",
verify: { ok: false, exitCode: 1, command: "npm test" },
hypotheses: [
{ claim: "cache it", status: "invalidated", reason: "stale" },
{ claim: "kept", status: "validated" },
],
}),
);
assert.match(block, /^\n\[worker result w1\]\n/);
assert.match(block, /create mode 120000 core\/dist/);
assert.match(block, /Verify: failed \(exit 1\): npm test/);
assert.match(block, /Ruled out:\n- cache it — stale/);
assert.doesNotMatch(block, /kept/);
assert.ok(block.endsWith("\n[/worker result]"));
});

it("lists review-itinerary risks as open items", () => {
fs.mkdirSync(path.join(repo, ".solenta/review-itinerary"), { recursive: true });
fs.writeFileSync(
path.join(repo, ".solenta/review-itinerary/w1.json"),
JSON.stringify({ chunks: [{ area: "ipc", risks: ["race"] }], risks: ["untested"] }),
);
const block = workerResultBlock(worker());
assert.match(block, /Open review items:\n- ipc: race\n- untested/);
});

it("truncates the body at the cap", () => {
for (let i = 0; i < 40; i++) {
fs.writeFileSync(path.join(repo, `file-with-a-long-name-${i}.txt`), "y\n");
}
git(repo, ["add", "-A"]);
git(repo, ["commit", "-qm", "many"]);
const block = workerResultBlock(
worker({
hypotheses: Array.from({ length: 30 }, (_, i) => ({
claim: `idea ${i} ${"z".repeat(60)}`,
status: "invalidated",
})),
}),
);
const body = block.split("\n").slice(2, -1).join("\n");
assert.ok(body.length <= WORKER_RESULT_MAX, `body ${body.length} chars`);
assert.match(body, /… \(truncated\)$/);
assert.ok(block.endsWith("\n[/worker result]"));
});

it("is empty when there is nothing to report", () => {
assert.equal(workerResultBlock({ id: "w2" }), "");
});
});
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2094,6 +2094,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
onFileSuggestion={handleFileSuggestion}
onDismissSuggestion={handleDismissSuggestion}
onOpenAutomations={handleOpenAutomation}
onIntegrateWorker={selectedThreadId ? integrateSelectedWorker : undefined}
handoffSource={handoffSource}
comparePeers={comparePeers}
onPeekThread={peekThread}
Expand Down
16 changes: 16 additions & 0 deletions src/components/ThreadView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2652,6 +2652,22 @@ button.diffLineGutter:focus-visible {
user-select: text;
}

.workerResultBody {
margin-top: 6px;
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}

.workerResultActions {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 6px;
font-size: 12px;
color: var(--text-muted);
}

.userBubbleWrap {
display: flex;
justify-content: flex-end;
Expand Down
4 changes: 4 additions & 0 deletions src/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,8 @@ interface ThreadViewProps {
onDismissSuggestion?: (s: WorkSuggestion) => void | Promise<void>;
/** Open Automations, optionally focusing a created row (#1599). */
onOpenAutomations?: (automationId?: string) => void;
/** Worker-result card button (#1427): integrate a worker into this lead. */
onIntegrateWorker?: (workerThreadId: string) => Promise<void>;
/**
* The thread this one was handed off from (handoffFrom), already resolved.
* Resolved by App rather than passing the whole list: the list gets a new
Expand Down Expand Up @@ -881,6 +883,7 @@ export const ThreadView = memo(function ThreadView({
onFileSuggestion,
onDismissSuggestion,
onOpenAutomations,
onIntegrateWorker,
handoffSource = null,
onSelectThread,
threadTitles,
Expand Down Expand Up @@ -4065,6 +4068,7 @@ export const ThreadView = memo(function ThreadView({
onLoadAttachmentImage={onLoadAttachmentImage}
onSelectThread={onSelectThread}
onOpenAutomations={onOpenAutomations}
onIntegrateWorker={onIntegrateWorker}
pinned={pinnedIds.has(entry.message.id)}
onTogglePin={
onSetMessagePins &&
Expand Down
Loading
Loading