Skip to content
Open
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
11 changes: 11 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,14 @@ Only the durable default tab can inherit legacy task-level resume history.
A new secondary tab starts a fresh session even if it is the first tab of a
different profile. Explicit `tab --resume SESSION_ID` and restoration of a
tab's own captured session continue to resume that specific session.
## Delivery metadata

Composition members optionally persist `pr_url`, `pr_number`, and `pr_provider`,
matching the host task's identity fields. Older records default them to absent;
live provider state remains in the PR store.

Profile-scoped `scratch/<task-id>/delivery.json` owns reviewed requests, saved
reply drafts, imported PR descriptions, and per-repository bulk results. Agents
write `.termic-delivery/<request-id>.json` reports in the task checkout, excluded
through Git's common `info/exclude` for both repositories and linked worktrees.
Reports are size-bounded and checked against the reviewed request scope.
41 changes: 41 additions & 0 deletions docs/ipc.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,44 @@ or silently choose a reasoning level.

A model-switch prompt sent after creating a tab cannot make the original
launch fresh and is not an equivalent substitute for launch arguments.
## Delivery commands

`task_delivery_repos`, `task_delivery_details`, and `task_delivery_log` provide
local inventory and on-demand provider evidence. `task_delivery_validate` binds
actions to recorded selectors, canonical paths, branches, HEADs, remotes,
working-tree fingerprints, and optional PR source revisions. All provider/Git
work runs on `spawn_blocking`.

`task_delivery_request { id, expected, drafts, kind, scope, evidenceKeys }` stores
a reviewed request; kind is `fix`, `replies`, `prs`, or `conflicts`. Evidence
keys (`{dir}:ci:{id}` / `{dir}:review:{id}`) snapshot the selected items'
canonical JSON on the request. `task_delivery_request_check` re-probes those
items and fails the send when anything was edited or re-run since review;
`task_delivery_request_amend` rewrites drafts/evidence/scope while a request
is still `prepared` (the send dialog's item picker). Agents write bounded JSON
reports into the ignored task `.termic-delivery` directory.
`task_delivery_requests` imports only requested thread keys/repositories; a
malformed report records its error on that one request instead of failing the
listing, and a corrected rewrite imports on the next read. Reports must be
JSON objects with `drafts`/`prs` as arrays when present; each list is fully
validated before anything is applied, so a bad entry never leaves a partial
import behind. Importing creates no PRs and posts no comments.
`task_delivery_request_status` tracks handoff outcomes through a checked
transition table: `prepared → queued/sent/failed/uncertain`,
`queued|sent → sent/failed/uncertain`, `uncertain → sent/failed`,
`failed → queued/sent`, `drafted → failed` (explicit dismiss), same-status
writes are no-ops. It returns the status it replaced; the send path marks
the request `queued` before the evidence check and verifies that previous
status equals the one it read, so `request_amend` can no longer swap drafts
under a prompt about to be typed and a dismiss landing mid-claim wins.
Its optional `agent` argument records the terminal tab the prompt went to —
the request card's "Open agent" jump target.
`task_delivery_draft_save` edits a local reply; `task_delivery_reply_post` posts
one explicitly selected reply to its original thread. A stable hidden comment
marker identifies uncertain posts during readback.

`task_delivery_pr_create` and `task_delivery_update` process selected repositories
sequentially and retain results per (repository, action) across retries and
restarts — a repo can hold an update row and a PR row at once.
`task_delivery_results` restores those results. `task_delivery_archive_ready`
keeps tasks open when a repository is missing, dirty, unknown, or undelivered.
21 changes: 21 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -1500,3 +1500,24 @@ reports this" and "not supported yet" only mean something next to each other,
and it is a decision made once, not a per-agent preference. And the link out of
Notifications does not gate on which agents are supported; the table is the
authority on that.
## Delivery workflow

The right panel's Delivery tab lists every recorded checkout and separates PR,
CI, and review state from agent activity. Provider details and failed-job log
excerpts load on demand. Select repositories and evidence, then review the exact
agent, checkout paths, branches, and revisions before sending or queuing. A
changed destination stops the handoff. Evidence is untrusted data; the prompt
does not authorize commits, pushes, posting replies, or merging.

Reply drafts require Save locally or Post this reply. Saved drafts survive
restarts; unsaved edits survive tab navigation within the session. Uncertain
posting outcomes use readback rather than automatic retries. A missing reply
requires a separate explicit retry, with fresh readback before posting again. Drafting PR text
does not create PRs. Bulk creation defaults to draft PRs, reuses open PRs, and
shows each repository's result. Branch updates show autostash behavior and retain
conflict results for manual or reviewed agent resolution.

The Board's persistent Needs action toggle filters delivery blockers without
moving cards between agent-activity columns. Compact links open Delivery.
Multi-repository auto-archive accounts for every checkout rather than relying
on the host PR merge alone. The existing opt-in comment watcher stays separate.
3 changes: 2 additions & 1 deletion e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface TermicApi {
useRace: { getState: () => any };
/** PR/MR store (src/store/pr.ts). Specs seed `byTask` directly to render
* card states without a real forge/network. */
useDelivery: { getState: () => any; setState: (p: any) => void };
usePr: { getState: () => any; setState: (p: any) => void };
/** Per-task change summaries (src/store/diffStat.ts). Demand-driven with a
* staleness floor, so a spec that changes a worktree calls `invalidate`
Expand Down Expand Up @@ -303,7 +304,7 @@ export async function waitForAppShell(timeout = 30_000): Promise<void> {
* clickable element with text: Git" whenever an earlier spec left a file
* behind, and passed when git.e2e ran alone.
*/
export async function openRightTab(label: "All files" | "Git"): Promise<void> {
export async function openRightTab(label: "All files" | "Git" | "Delivery"): Promise<void> {
await browser.execute((l) => {
const el = document.querySelector(
`[data-testid="right-tab"][data-tab="${l}"]`,
Expand Down
3 changes: 3 additions & 0 deletions e2e/specs/agent.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,9 @@ describe("pending work defers done", () => {
await waitForAppShell();
await requireTermicApi();
await requireWorkBadges();
// A previously interrupted ceiling test can leave its 8s override behind.
// This case exercises the normal hold, so establish the default before spawn.
await browser.execute(() => localStorage.removeItem("workDoneCeilingMs"));
taskId = await openTask("e2e-pending-work");
await waitForAgentReady(taskId);

Expand Down
25 changes: 25 additions & 0 deletions e2e/specs/board.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,31 @@ describe("board view", () => {
await snap("board-card-pr.png");
});

it("opens Delivery from the actionable CI status without a generic Delivery row", async () => {
await browser.execute(id => {
const store = window.__termic!.usePr;
store.setState({ byTask: { ...store.getState().byTask, [id]: {
lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 42,
url: "https://example.test/pull/42", state: "open", checks: "failing", review: "changes_requested",
title: "Review fixture", base: "main", head: "topic" } },
loading: false, fetchedAt: Date.now(),
} } });
const app = window.__termic!.useApp.getState();
if (!app.rightPanelHidden) app.toggleRightPanel();
}, t2);
const selector = CARD(t2) + ' [data-testid="board-card-delivery"]';
await waitVisible(selector);
const text = await browser.execute(sel => document.querySelector(sel)?.textContent ?? "", selector);
expect(text).toContain("CI");
expect(text).not.toContain("Delivery");
await browser.execute(sel => (document.querySelector(sel) as HTMLElement).click(), selector);
await waitVisible('[data-testid="delivery-panel"]');
// Put the suite back on its Board and keep later editor suites on Files.
await browser.execute(() => (document.querySelector('[data-testid="right-tab"][data-tab="All files"]') as HTMLElement).click());
await clickByText("Kanban");
await waitVisible('[data-testid="board-view"]');
});

it("a wide PR chip and churn never make the column scroll sideways", async () => {
// Shipped broken in 1.11.2: the chip and the churn were both shrink-0 on
// one line, so "#18495 - checks failing" next to "+356 -21 12 files"
Expand Down
119 changes: 119 additions & 0 deletions e2e/specs/delivery.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { writeFileSync } from "node:fs";
import { archiveTask, clickByText, clickWhenVisible, clickMenuItem, createWorktreeTask, openRightTab, requireTermicApi, waitGone, waitVisible, waitForAgentReady, waitForAppShell, waitForText } from "../helpers";

const openActions = async () => {
await browser.waitUntil(() => browser.execute(() => !document.querySelector<HTMLButtonElement>('[data-testid="delivery-actions"]')?.disabled));
await browser.execute(() => {
const trigger = document.querySelector('[data-testid="delivery-actions"]')!;
const init = { bubbles: true, cancelable: true, button: 0, pointerType: "mouse", isPrimary: true };
trigger.dispatchEvent(new PointerEvent("pointerdown", init));
trigger.dispatchEvent(new PointerEvent("pointerup", init));
});
await waitVisible('[role="menu"]');
};
const refreshPanel = async () => {
const selector = '[data-testid="delivery-panel"] button[aria-label="Refresh"]';
await browser.waitUntil(() => browser.execute(s => !document.querySelector<HTMLButtonElement>(s)?.disabled, selector));
await clickWhenVisible(selector);
};
describe("task delivery", () => {
let taskId = "";
after(async () => { await browser.keys("Escape"); await openRightTab("All files"); if (taskId) await archiveTask(taskId); });

it("shows clean repositories and retains failed branch-update results", async () => {
await waitForAppShell();
await requireTermicApi();
taskId = await createWorktreeTask("e2e-delivery", "e2e-delivery");
await openRightTab("Delivery");
await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-repo"]')?.textContent?.includes("Clean")));
await openActions();
await clickMenuItem("Update branches");
await browser.execute(() => { const select = document.querySelector<HTMLSelectElement>('[role="dialog"] select')!; select.value = "pull"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await clickByText("Update branches");
await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream")));
await openRightTab("All files");
await openRightTab("Delivery");
await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream")));
});

it("reviews exact scope and rejects a changed worktree before sending", async () => {
await waitForAgentReady(taskId);
await openActions();
await clickMenuItem("Create PRs");
await clickByText("Draft all with agent");
await waitForText("Review agent handoff");
await waitForText("does not authorize commits");
await browser.execute(async id => {
await window.__termic!.ipc.taskFileWrite(id, "README.md", "changed after reviewing the handoff\n");
}, taskId);
await browser.waitUntil(() => browser.execute(() => !document.querySelector<HTMLButtonElement>('[data-testid="delivery-send"]')?.disabled));
await clickWhenVisible('[data-testid="delivery-send"]');
await browser.waitUntil(() => browser.execute(() => document.querySelector('[role="dialog"]')?.textContent?.includes("working files changed")));
await browser.keys("Escape");
await browser.keys("Escape");
await waitGone('[role="dialog"]');
await browser.execute(async id => {
// Undo through git in the fixture's isolated checkout, not the shared repo.
const t = window.__termic!;
await t.ipc.taskDiscard(id, "", ["README.md"]);
}, taskId);
});

it("imports an agent PR report and auto-fills untouched dialog fields", async () => {
await refreshPanel();
await browser.waitUntil(() => browser.execute(id => !window.__termic!.useDelivery.getState().byTask[id]?.loading, taskId));
await openActions();
await clickMenuItem("Create PRs");
await clickByText("Draft all with agent");
await waitForText("Review agent handoff");
const request = await browser.execute(async id => {
const requests = await window.__termic!.ipc.taskDeliveryRequests(id);
return requests[requests.length - 1];
}, taskId);
// Simulate an agent writing its report, using the reviewed fixture path.
writeFileSync(request.report, JSON.stringify({ drafts: [], prs: [
{ dir_name: "", title: "Proposed delivery title", body: "Proposed description, pending human review." },
] }));
await browser.execute(async (id, requestId) => {
await window.__termic!.ipc.taskDeliveryRequestStatus(id, requestId, "sent");
}, taskId, request.id);
await browser.waitUntil(() => browser.execute(() => !document.querySelector<HTMLButtonElement>('[data-testid="delivery-send"]')?.disabled));
await browser.keys("Escape");
// The imported draft lands in the still-open Create PRs dialog, filling
// only fields the user has not touched.
await browser.execute(async id => { await window.__termic!.useDelivery.getState().refresh(id); }, taskId);
await browser.waitUntil(() => browser.execute(() => document.querySelector<HTMLInputElement>('[role="dialog"] input')?.value === "Proposed delivery title"));
expect(await browser.execute(() => document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')?.checked)).toBe(true);
await waitForText("pushes committed branch changes");
await browser.keys("Escape");
await waitGone('[role="dialog"]');
await waitForText("Proposed delivery title");
});

it("renders independent review and CI states without treating skipped jobs as passed", async () => {
await browser.execute(id => {
const store = window.__termic!.useDelivery;
const current = store.getState().byTask[id];
const identity = current.repos[0].identity;
window.__termic!.usePr.setState({ byTask: { ...window.__termic!.usePr.getState().byTask, [id]: {
lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 7, state: "open", checks: "failing", review: "changes_requested", title: "Handle missing branch", url: "https://example.test/pull/7" } },
loading: false, fetchedAt: Date.now(),
} } });
store.setState({ byTask: { ...store.getState().byTask, [id]: { ...current, details: {
"": { identity, revision: "abc123", pr: { provider: "github", number: 7, url: "https://example.test/pull/7" },
ci_error: null, threads_error: null,
ci: [{ id: "run", parent: null, name: "Build workflow", status: "failed", duration: null, url: "", log_id: null },
{ id: "job", parent: "run", name: "Skipped deployment", status: "skipped", duration: null, url: "", log_id: null }],
threads: [{ id: "thread", reply_id: "1", path: "src/example.ts", line: 12, resolved: false, url: "",
comments: [{ id: "1", author: "Reviewer", body: "Please handle the missing branch." }] }],
},
} } } });
}, taskId);
await clickWhenVisible('[data-testid="delivery-repo-details"]');
await waitForText("Build workflow");
await waitForText("Skipped deployment");
await waitForText("skipped");
await waitForText("src/example.ts:12 · Unresolved");
await waitForText("Please handle the missing branch.");
});
});
2 changes: 2 additions & 0 deletions e2e/specs/settings.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3369,6 +3369,8 @@ describe("agent hooks", () => {
"theme_mode": "dark"
}
`;
// A failed prior run can retain its original-config backup.
rmTree(devinDir, { bestEffort: true });
mkdirSync(devinDir, { recursive: true });
writeFileSync(devinConfig, userDevinConfig);

Expand Down
Loading
Loading