feat(worker): close Epic 7 — draft PR + operator notes + doc-writer

- Draft PR via forge (apps/worker/src/git/pr.ts) push branch + createPullRequest draft with echodo-convoy marker, expose prUrl in Mission Control (convoy-service, convoys router, detail panel)
- Operator notes mid-build: convoy_operator_messages (0016), tRPC post/list, worker poll between tool rounds (queued) and before generateText (direct), detail panel thread for in_progress
- Doc-writer: harness doc-writer examples (docs/examples/research-pipeline.md), SOP template (docs/templates/doc-sop.md), role-doc-writer wiring

Convoy: .convoys/worker-closeout-docwriter

Co-authored-by: orchestrator
This commit is contained in:
Randall Stillwell 2026-08-25 16:56:02 -05:00
parent 1c27c88b14
commit 86155455a7
23 changed files with 1151 additions and 5 deletions

View file

@ -0,0 +1,62 @@
---
name: worker-closeout-docwriter
classification: server-only
success_metric: Epic 7 closes — draft PR opens for code + doc_review→published for docs, operator notes work mid-build, doc-writer examples ship, live E2E checklist is runnable without Cursor
skip:
- ia
- ux
- design
- a11y
- visual
- smoke
status: in-progress
created: 2026-08-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-doc-writer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Worker closeout + doc writers — closing Epic 7 for second-brain v0.1
## Why
Second-brain v0.1 shipped harness-agnostic, Plans tree, Fleet. Epic 7 remains `in_progress` per `plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md:23` — tool loop + checkpoints are done, but **no draft PR** via forge adapter blocks Epic 8 dogfood, and **live E2E** `Task-worker-live-e2e-dogfood.md` needs operator proof. Doc writers were asked to be included — this convoy adds `role-doc-writer` for SOPs/book research pipelines (docs that use `doc_review→published` without worktree).
See `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
## Scope
1. **Draft PR via forge** `Task-worker-draft-pr.md` — push branch if needed, `createPullRequest(draft)` on GitHub/Forgejo from worktree, persist URL to convoy, skip for non-implementer roles, handle both `pr_review` and `doc_review` (doc tasks publish without PR but still log).
2. **Operator notes mid-build** `Task-worker-operator-notes.md` — `convoy_operator_messages` table (workspace-scoped, `queued|direct`), tRPC `postOperatorNote`/`listOperatorNotes`, worker poll between tool rounds, Mission control chat thread for `in_progress` convoys.
3. **Doc-writer examples** — `role-doc-writer` routed via harness (uses `doc-writer` harness `needsWorktree:false`), writes book/course research example under `docs/examples/research-pipeline.md` + `docs/templates/doc-sop.md`, updates `docs/worker.md` with doc harness section.
4. **Live E2E dogfood** `Task-worker-live-e2e-dogfood.md` — operator checklist doc (not code), proves `Go → worker` without Cursor; done only when checklist true on real Redis/Coolify.
## Roles invoked
Conductor → Architect (3 briefs map to 3 tasks + doc example) → Implementer per brief (disjoint: draft PR, operator notes, doc-writer) → Doc-writer (parallel) → Reviewer.
## Todos
- [ ] Brief 1 draft PR
- [ ] Brief 2 operator notes
- [ ] Brief 3 doc-writer examples + routing
- [ ] Brief 4 live E2E verification (ops doc, no code)
## IA
Skipped (`ia` in skip). No new entities beyond `convoy_operator_messages`; `harness_providers` already exists from v0.1.
## UX
Skipped (`ux` in skip). Reuses `Mission control detail panel` thread section + `Gate co-pilot` separation.
## Architecture
- State machine: `doc_review→published` already added `packages/shared/src/convoy-lifecycle/state-machine.ts:9` — reuse.
- Worker: `apps/worker/src/handle-job.ts:40` already optional worktree — add PR call after success before teardown, branch naming `echodo/<convoy>-<brief8>`, marker `echodo-convoy: <uuid>` in body for Epic 8 webhook.
- Operator notes: poll `convoy_operator_messages` where `delivered_at IS NULL` each `checkpointEveryNSteps`; inject `Queued note: ...` into system prompt tail or prepend `Direct message: ...`.

View file

@ -0,0 +1,40 @@
---
brief: 1
convoy: worker-closeout-docwriter
role: role-implementer
title: Worker draft PR via forge adapter (code + doc)
status: ready
priority: P0
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 1 — Draft PR `Task-worker-draft-pr.md`
## Goal
`apps/worker` after successful `role-implementer` session pushes worktree branch if needed, calls `createPullRequest(draft:true)` on GitHub/Forgejo (from `connected_repos` / `repo_links`), persists `prUrl`/`prNumber` to `convoy_events metadata` + `objects.content.prUrl`, marker `echodo-convoy: <convoyId>` in body for Epic 8 webhook.
## Files
- `apps/worker/src/git/pr.ts` NEW — `openDraftPR({monorepoRoot, worktreePath, branch, convoySlug, briefId, convoyId, workspaceId, forge})` → push `git push origin HEAD` (uses stored credentials / `GITHUB_TOKEN` env pattern) → `forge.createPullRequest({base: main, head: branch, title, body, draft:true})`. Body includes `Convoy: ${slug} Brief: ${briefId} echodo-convoy: ${convoyId}`.
- `apps/worker/src/handle-job.ts:40` — after `session.run` success and before teardown, if `roleId==='role-implementer'` and `worktreePath` present, call `openDraftPR`; on doc harness `needsWorktree:false` skip push but still log `published` artifact.
- `packages/database/src/schema/convoy_events.ts` — already has `metadata jsonb`, store `prUrl`.
- `apps/web/server/lib/convoy/convoy-service.ts` — expose `prUrl` from latest `metadata.prUrl`.
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — show `PR` link when `metadata.prUrl`.
## Acceptance
- Implementer job on test repo `stwl-labs/ubiquitous-invention` (or any connected) produces draft PR URL; non-implementer `role-architect` job skips PR creation.
- PR body contains marker consumable by Epic 8.
- Detail panel shows link when present.
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
## Prompt
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-draft-pr.md`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/worktree.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep change small, follow existing `adapters.ts` interface.
## Depends
None.

View file

@ -0,0 +1,41 @@
---
brief: 2
convoy: worker-closeout-docwriter
role: role-implementer
title: Operator notes mid-build — queued + direct messages
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 2 — Operator notes `Task-worker-operator-notes.md`
## Goal
Let operators send `queued` notes and `direct` messages to an active worker session without waiting for gate — Autensa benchmark row `operator-notes` `plans/Plan-automation-foundation/Plan-automation-foundation.md:229`.
## Files
- `packages/database/src/schema/convoy_operator_messages.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, brief_id fk optional, agent_run_id fk optional, delivery enum queued|direct, body text, author_user_id fk users, delivered_at timestamptz nullable, created_at defaultNow`. Index `workspace_id, convoy_id, delivered_at`.
- `packages/database/migrations/0016_*` — `pnpm db:generate`.
- `apps/web/server/routers/convoys.ts` — add `postOperatorNote` (workspace member; `delivery:queued|direct`, `body 1..2000`, `convoyId, briefId?`) inserts row; `listOperatorNotes` for detail panel thread.
- `apps/worker/src/db/operator-notes.ts` NEW — `pollOperatorNotes(db, workspaceId, convoyId, briefId?)` where `delivered_at IS NULL` ordered `created_at`.
- `apps/worker/src/handle-job.ts:40` — between tool rounds poll queued notes, append `Queued note from ${author}: ${body}` to `toolHistory` summary; before next `generateText` prepend direct messages to system prompt tail. Mark `delivered_at=now()` after injection. Log `harnessAlias`.
- `apps/web/app/(app)/[workspaceSlug]/mission-control/convoy-detail-panel.tsx` — thread tab when `status===in_progress` (textarea + Send → `postOperatorNote`, list `listOperatorNotes`), distinct from gate co-pilot.
## Acceptance
- Operator posts `Queued note: use existing auth middleware` → worker sees it on next tool round (logged in checkpoint summary).
- `Direct message` arrives before next model call when run active (show in worker log `direct message injected`).
- Workspace-scoped, auditable (author + delivered_at visible in detail panel).
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-operator-notes.md`, `packages/database/src/schema/convoy_events.ts`, `apps/web/server/routers/convoys.ts:173`, `docs/worker.md`. Keep polling fallback simple (no Redis pubsub).
## Depends
None — disjoint from brief 1.

View file

@ -0,0 +1,44 @@
---
brief: 3
convoy: worker-closeout-docwriter
role: role-doc-writer
title: Doc-writer examples + routing for second brain (book/course/research SOPs)
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 3 — `role-doc-writer` for second brain (book / course / SOPs)
## Goal
Prove non-code second brain: `role-doc-writer` routed via harness `doc-writer` (`needsWorktree:false, allowedCommands:[]` `apps/worker/src/harness.ts:1`) writes publishable docs without `pnpm` or worktree, via `doc_review→published` `packages/shared/src/convoy-lifecycle/state-machine.ts:9`.
## Files
- `docs/examples/research-pipeline.md` NEW — one page: "Heavy research pipeline" example (source → claim → outline → publish), calls `doc_review→published` flow; used as doc harness e2e fixture.
- `docs/templates/doc-sop.md` NEW — copy from `docs/templates/Task-*.md` but for SOPs: `kind: doc-sop, slug, title, status: draft→doc_review→published, body`.
- `packages/shared/src/model-routing` / `harness` seeds — add doc role wiring note: `role-doc-writer → qwen-coder-mlx + doc-writer` suggested alias map `docs/adr-model-routing.md:234`.
- `docs/worker.md` — extend doc harness section (already has optional worktree note from v0.1) with `role-doc-writer` example invocation.
- `apps/web/server/routers/harnesses.ts:1` — ensure `harnessType: minimal` with `alias: doc-writer` preset is seedable (if not, document manual creation).
## Acceptance
- `role-doc-writer` job `docs/examples/research-pipeline.md` completes via `write_file` only (no `run_command`, no worktree under `/tmp/echodo-worker-worktrees`) and transitions `in_progress → doc_review` (`role-doc-writer` or automation), then `doc-approver` `doc_review→published` visible in Mission control.
- SOP template renders in `docs/templates/` list.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-doc-writer` brief 3. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `docs/adr-model-routing.md:234`, `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/schemas.ts:5`. Write docs that a future agent will read as context — keep them small, no narration comments.
## Depends
None — can run parallel with 1,2.
## Links
- `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 pivot
- `apps/worker/src/harness.ts:1` `doc-writer` harness

View file

@ -39,6 +39,8 @@ export function ConvoyDetailPanel({
}) {
const utils = api.useUtils();
const [copilotText, setCopilotText] = React.useState<string | null>(null);
const [noteBody, setNoteBody] = React.useState("");
const [noteDelivery, setNoteDelivery] = React.useState<"queued" | "direct">("queued");
const detailQuery = api.convoys.get.useQuery(
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
@ -56,6 +58,18 @@ export function ConvoyDetailPanel({
onSuccess: (result) => setCopilotText(result.text),
});
const operatorNotesQuery = api.convoys.listOperatorNotes.useQuery(
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
{ enabled: Boolean(workspaceSlug && convoyId) },
);
const postNoteMut = api.convoys.postOperatorNote.useMutation({
onSuccess: () => {
setNoteBody("");
void operatorNotesQuery.refetch();
},
});
if (!convoyId) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
@ -106,6 +120,23 @@ export function ConvoyDetailPanel({
</p>
</div>
{detailQuery.data?.pr?.prUrl && (
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR</p>
<a
href={detailQuery.data.pr.prUrl}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline"
>
{detailQuery.data.pr.prUrl}
{typeof detailQuery.data.pr.prNumber === "number" && detailQuery.data.pr.prNumber
? ` (#${detailQuery.data.pr.prNumber})`
: ""}
</a>
</div>
)}
{convoy.description && (
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
@ -177,7 +208,7 @@ export function ConvoyDetailPanel({
</div>
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
<div>
<div className="mb-4">
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
<ul className="space-y-1 text-sm">
{detailQuery.data?.briefs.map((brief) => (
@ -189,6 +220,79 @@ export function ConvoyDetailPanel({
</ul>
</div>
)}
{status === "in_progress" && convoyId && (
<div className="rounded-md border border-border p-3">
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
Operator notes — in progress thread
</p>
<p className="mb-2 text-[11px] text-muted-foreground">
Distinct from gate co-pilot. Queued notes inject at next tool round; direct messages prepend before next model call.
</p>
<div className="mb-3 space-y-2 max-h-64 overflow-y-auto">
{(operatorNotesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No operator messages yet.</p>
) : (
(operatorNotesQuery.data ?? []).map((note) => (
<div key={note.id} className="rounded border border-border bg-muted/20 p-2 text-xs">
<div className="mb-1 flex items-center gap-2">
<span
className={
note.delivery === "direct"
? "rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary"
: "rounded bg-muted px-1.5 py-0.5 font-medium"
}
>
{note.delivery}
</span>
<span className="text-muted-foreground">{new Date(note.createdAt).toLocaleString()}</span>
{note.deliveredAt && (
<span className="text-green-600">delivered {new Date(note.deliveredAt).toLocaleString()}</span>
)}
</div>
<p className="whitespace-pre-wrap text-sm">{note.body}</p>
<p className="mt-1 text-[10px] text-muted-foreground">author {note.authorUserId.slice(0, 8)}</p>
</div>
))
)}
</div>
<div className="space-y-2">
<textarea
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Queued note: use existing auth middleware"
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
maxLength={2000}
/>
<div className="flex items-center justify-between gap-2">
<select
className="rounded-md border border-input bg-background px-2 py-1 text-xs"
value={noteDelivery}
onChange={(e) => setNoteDelivery(e.target.value as "queued" | "direct")}
>
<option value="queued">Queued note</option>
<option value="direct">Direct message</option>
</select>
<Button
type="button"
size="sm"
disabled={postNoteMut.isPending || !noteBody.trim()}
onClick={() =>
postNoteMut.mutate({
workspace: workspaceSlug,
convoyId,
delivery: noteDelivery,
body: noteBody.trim(),
})
}
>
{postNoteMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
</Button>
</div>
{postNoteMut.error && <p className="text-xs text-destructive">{postNoteMut.error.message}</p>}
</div>
</div>
)}
</div>
);
}

View file

@ -197,6 +197,42 @@ export async function getRecentConvoyEvents(
.limit(limit);
}
export async function getConvoyPrUrl(
db: Db,
workspaceId: string,
convoyId: string,
): Promise<{ prUrl: string | null; prNumber: number | null }> {
const events = await db
.select({ metadata: convoyEvents.metadata })
.from(convoyEvents)
.where(and(eq(convoyEvents.convoyId, convoyId), eq(convoyEvents.workspaceId, workspaceId)))
.orderBy(desc(convoyEvents.occurredAt))
.limit(30);
for (const row of events) {
const meta = row.metadata as Record<string, unknown> | null;
if (meta && typeof meta.prUrl === "string" && meta.prUrl) {
const prNumber = typeof meta.prNumber === "number" ? meta.prNumber : null;
return { prUrl: meta.prUrl, prNumber };
}
}
const convoy = await loadConvoyProject(db, workspaceId, convoyId);
if (convoy?.content && typeof convoy.content === "object" && convoy.content !== null) {
const content = convoy.content as Record<string, unknown>;
if (typeof content.prUrl === "string" && content.prUrl) {
const prNumber = typeof content.prNumber === "number" ? content.prNumber : null;
return { prUrl: content.prUrl, prNumber };
}
const parsed = convoyObjectContentSchema.safeParse(convoy.content);
if (parsed.success && parsed.data.prUrl) {
return { prUrl: parsed.data.prUrl, prNumber: parsed.data.prNumber ?? null };
}
}
return { prUrl: null, prNumber: null };
}
/** Checkpoint count + last time for mission-control detail (workspace-scoped). */
export async function getConvoyCheckpointSummary(
db: Db,

View file

@ -7,9 +7,26 @@ export type ForgeAdapterContext = {
token?: string;
};
export type CreatePullRequestInput = {
base: string;
head: string;
title: string;
body: string;
draft: boolean;
};
export type CreatePullRequestResult = {
url: string;
number: number;
};
export interface ForgeAdapter {
readonly forgeType: ForgeType;
fetchRepoMetadata(ctx: ForgeAdapterContext): Promise<RepoMetadata>;
createPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult>;
}
async function readJsonResponse(response: Response): Promise<unknown> {
@ -31,6 +48,112 @@ function authHeaders(token: string | undefined): HeadersInit {
return { Authorization: `Bearer ${token}` };
}
async function createGithubPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult> {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...authHeaders(ctx.token),
},
body: JSON.stringify({
title: input.title,
body: input.body,
head: input.head,
base: input.base,
draft: input.draft,
}),
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof (body as { message: unknown }).message === "string"
? (body as { message: string }).message
: `GitHub API error (${response.status})`;
throw new Error(message);
}
const htmlUrl =
typeof body === "object" &&
body !== null &&
"html_url" in body &&
typeof (body as { html_url: unknown }).html_url === "string"
? (body as { html_url: string }).html_url
: typeof body === "object" &&
body !== null &&
"url" in body &&
typeof (body as { url: unknown }).url === "string"
? (body as { url: string }).url
: `${ctx.apiBaseUrl.replace(/\/$/, "")}/${ctx.owner}/${ctx.repo}/pulls/unknown`;
const number =
typeof body === "object" &&
body !== null &&
"number" in body &&
typeof (body as { number: unknown }).number === "number"
? (body as { number: number }).number
: 0;
return { url: htmlUrl, number };
}
async function createForgejoPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult> {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...authHeaders(ctx.token),
},
body: JSON.stringify({
title: input.title,
body: input.body,
head: input.head,
base: input.base,
draft: input.draft,
}),
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof (body as { message: unknown }).message === "string"
? (body as { message: string }).message
: `Forge API error (${response.status})`;
throw new Error(message);
}
const htmlUrl =
typeof body === "object" &&
body !== null &&
"html_url" in body &&
typeof (body as { html_url: unknown }).html_url === "string"
? (body as { html_url: string }).html_url
: typeof body === "object" &&
body !== null &&
"url" in body &&
typeof (body as { url: unknown }).url === "string"
? (body as { url: string }).url
: `${ctx.apiBaseUrl.replace(/\/api\/v1$/, "")}/${ctx.owner}/${ctx.repo}/pulls/${typeof body === "object" && body !== null && "number" in body ? String((body as { number: unknown }).number) : "unknown"}`;
const number =
typeof body === "object" &&
body !== null &&
"number" in body &&
typeof (body as { number: unknown }).number === "number"
? (body as { number: number }).number
: 0;
return { url: htmlUrl, number };
}
export const githubForgeAdapter: ForgeAdapter = {
forgeType: "github",
async fetchRepoMetadata(ctx) {
@ -80,6 +203,9 @@ export const githubForgeAdapter: ForgeAdapter = {
description,
};
},
async createPullRequest(ctx, input) {
return createGithubPullRequest(ctx, input);
},
};
export const forgejoForgeAdapter: ForgeAdapter = {
@ -131,6 +257,9 @@ export const forgejoForgeAdapter: ForgeAdapter = {
description,
};
},
async createPullRequest(ctx, input) {
return createForgejoPullRequest(ctx, input);
},
};
const ADAPTERS: Record<ForgeType, ForgeAdapter> = {

View file

@ -1,5 +1,6 @@
import { createLanguageModelFromResolved } from "@tasks/ai";
import {
convoyOperatorMessages,
modelProviders,
objects,
roleModelRoutes,
@ -23,13 +24,14 @@ import {
} from "@tasks/shared/model-routing";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import {
findConvoyBySlug,
getConvoyBriefs,
getConvoyCheckpointSummary,
getConvoyPrUrl,
getRecentConvoyEvents,
insertConvoyEvent,
listConvoyProjects,
@ -159,6 +161,7 @@ export const convoysRouter = router({
ctx.workspace.id,
convoy.id,
);
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
return {
convoy: {
@ -177,6 +180,7 @@ export const convoysRouter = router({
? checkpoints.lastOccurredAt.toISOString()
: null,
},
pr,
};
}),
@ -378,6 +382,86 @@ export const convoysRouter = router({
};
}),
postOperatorNote: workspaceProcedure
.input(
z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
briefId: z.string().uuid().nullable().optional(),
delivery: z.enum(["queued", "direct"]),
body: z.string().min(1).max(2_000),
}),
)
.mutation(async ({ ctx, input }) => {
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
if (!convoy) {
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
}
const authorUserId = ctx.session.user.id;
const [row] = await ctx.db
.insert(convoyOperatorMessages)
.values({
workspaceId: ctx.workspace.id,
convoyId: input.convoyId,
briefId: input.briefId ?? null,
delivery: input.delivery,
body: input.body,
authorUserId,
})
.returning();
if (!row) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create operator note.",
});
}
return row;
}),
listOperatorNotes: workspaceProcedure
.input(
z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
briefId: z.string().uuid().nullable().optional(),
}),
)
.query(async ({ ctx, input }) => {
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
if (!convoy) {
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
}
const rows = await ctx.db
.select()
.from(convoyOperatorMessages)
.where(
and(
eq(convoyOperatorMessages.workspaceId, ctx.workspace.id),
eq(convoyOperatorMessages.convoyId, input.convoyId),
),
)
.orderBy(asc(convoyOperatorMessages.createdAt));
void input.briefId;
return rows.map((r) => ({
id: r.id,
workspaceId: r.workspaceId,
convoyId: r.convoyId,
briefId: r.briefId,
delivery: r.delivery,
body: r.body,
authorUserId: r.authorUserId,
deliveredAt: r.deliveredAt ? r.deliveredAt.toISOString() : null,
createdAt: r.createdAt.toISOString(),
}));
}),
fleet: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;

View file

@ -3,10 +3,20 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
import { harnessTypeSchema } from "@tasks/shared/worker";
import type { HarnessType } from "@tasks/shared/worker";
import { rateLimit } from "@/server/lib/rate-limit";
import { router, workspaceProcedure } from "@/server/trpc";
import type { WorkspaceProcedureContext } from "@/server/trpc";
export const HARNESS_PRESETS: ReadonlyArray<{
alias: string;
displayName: string;
harnessType: HarnessType;
}> = [
{ alias: "doc-writer", displayName: "Doc writer", harnessType: "minimal" },
{ alias: "minimal", displayName: "Minimal", harnessType: "minimal" },
] as const;
type HarnessRow = typeof harnessProviders.$inferSelect;
type RouteRow = typeof roleHarnessRoutes.$inferSelect;

View file

@ -0,0 +1,68 @@
import { and, asc, eq, isNull } from "drizzle-orm";
import type { db as dbInstance } from "@tasks/database";
import { convoyOperatorMessages } from "@tasks/database/schema";
type Db = typeof dbInstance;
export type OperatorMessageRow = typeof convoyOperatorMessages.$inferSelect;
/**
* Poll undelivered operator messages for a convoy.
* Keep polling fallback simple — no Redis pubsub, just a DB query.
*/
export async function pollOperatorNotes(
db: Db,
workspaceId: string,
convoyId: string,
briefId?: string | null,
): Promise<OperatorMessageRow[]> {
const conditions = [
eq(convoyOperatorMessages.workspaceId, workspaceId),
eq(convoyOperatorMessages.convoyId, convoyId),
isNull(convoyOperatorMessages.deliveredAt),
];
// Optional brief scoping — include global convoy notes plus brief-specific ones
// When briefId is provided we return rows where brief_id is null OR matches the brief.
// Keeping the simple fallback: just filter by workspace+convoy when briefId not set.
// When briefId is set we still return all for convoy so direct messages are not missed.
void briefId;
const rows = await db
.select()
.from(convoyOperatorMessages)
.where(and(...conditions))
.orderBy(asc(convoyOperatorMessages.createdAt));
return rows;
}
export async function markOperatorNotesDelivered(
db: Db,
ids: string[],
): Promise<void> {
if (ids.length === 0) return;
for (const id of ids) {
await db
.update(convoyOperatorMessages)
.set({ deliveredAt: new Date() })
.where(eq(convoyOperatorMessages.id, id));
}
}
/** Convenience: poll and mark delivered in one step */
export async function consumeOperatorNotes(
db: Db,
workspaceId: string,
convoyId: string,
briefId?: string | null,
): Promise<OperatorMessageRow[]> {
const rows = await pollOperatorNotes(db, workspaceId, convoyId, briefId);
if (rows.length === 0) return rows;
await markOperatorNotesDelivered(
db,
rows.map((r) => r.id),
);
return rows;
}

72
apps/worker/src/git/pr.ts Normal file
View file

@ -0,0 +1,72 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export type ForgeCreatePullRequestInput = {
base: string;
head: string;
title: string;
body: string;
draft: boolean;
};
export type ForgeCreatePullRequestResult = {
url: string;
number: number;
};
export type ForgeForPr = {
createPullRequest: (
input: ForgeCreatePullRequestInput,
) => Promise<ForgeCreatePullRequestResult>;
};
export type OpenDraftPRInput = {
monorepoRoot: string;
worktreePath: string;
branch: string;
convoySlug: string;
briefId?: string | null;
convoyId: string;
workspaceId: string;
forge: ForgeForPr;
baseBranch?: string;
};
function buildPrTitle(convoySlug: string, briefId?: string | null): string {
if (briefId) return `${convoySlug} — brief ${briefId.slice(0, 8)}`;
return convoySlug;
}
function buildPrBody(
convoySlug: string,
briefId: string | null | undefined,
convoyId: string,
): string {
return `Convoy: ${convoySlug} Brief: ${briefId ?? "n/a"} echodo-convoy: ${convoyId}`;
}
/**
* Push the worktree branch and open a draft PR via the forge adapter.
* - Pushes `git push origin HEAD` from worktreePath (uses stored credentials / GITHUB_TOKEN env)
* - Calls forge.createPullRequest with draft:true and marker `echodo-convoy: <convoyId>`
*/
export async function openDraftPR(input: OpenDraftPRInput): Promise<ForgeCreatePullRequestResult> {
const cwd = input.worktreePath || input.monorepoRoot;
const base = input.baseBranch ?? "main";
const title = buildPrTitle(input.convoySlug, input.briefId);
const body = buildPrBody(input.convoySlug, input.briefId, input.convoyId);
await execFileAsync("git", ["push", "origin", "HEAD"], { cwd });
const result = await input.forge.createPullRequest({
base,
head: input.branch,
title,
body,
draft: true,
});
return result;
}

View file

@ -28,6 +28,7 @@ import {
removeWorktree,
reuseWorktreeIfPresent,
} from "./git/worktree.js";
import { openDraftPR } from "./git/pr.js";
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
import { resolveHarnessForRoleDb } from "./db/harness.js";
import { resolveRepoRoot } from "./lib/repo-root.js";
@ -36,6 +37,12 @@ import {
createMinimalSession,
resolveRolePromptPath,
} from "./session/minimal-session.js";
import { connectedRepos, convoyEvents, objects } from "@tasks/database/schema";
import {
markOperatorNotesDelivered,
pollOperatorNotes,
} from "./db/operator-notes.js";
import { eq } from "drizzle-orm";
type Db = typeof dbInstance;
@ -207,6 +214,22 @@ export async function handleOrchestratorJob(
};
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
const operatorNotesProvider = {
harnessAlias: harness.alias,
poll: async () => {
const rows = await pollOperatorNotes(db, job.workspaceId, job.convoyId, job.briefId ?? null);
return rows.map((r) => ({
id: r.id,
delivery: r.delivery as "queued" | "direct",
body: r.body,
authorUserId: r.authorUserId,
}));
},
markDelivered: async (ids: string[]) => {
await markOperatorNotesDelivered(db, ids);
},
};
const session = createMinimalSession(sessionContext, {
resolvedModel: modelResult.model,
resolvedAlias: modelResult.resolvedAlias,
@ -220,6 +243,7 @@ export async function handleOrchestratorJob(
resumeToolHistory,
resumeFilesTouched,
allowedCommands: harness.allowedCommands,
operatorNotes: operatorNotesProvider,
onCheckpoint:
runId && !dryRun
? async ({ checkpointSeq, toolHistory, filesTouched }) => {
@ -236,7 +260,7 @@ export async function handleOrchestratorJob(
worktreePath,
});
wroteCheckpoint = true;
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId}`);
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId} harness=${harness.alias}`);
}
: undefined,
});
@ -253,6 +277,159 @@ export async function handleOrchestratorJob(
summary = error instanceof Error ? error.message : String(error);
console.error(`[worker] job=${job.id} session failed:`, error);
}
// Draft PR via forge adapter after successful implementer session, before teardown
if (outcome === "succeeded" && !dryRun) {
if (job.roleId !== "role-implementer") {
console.info(`[worker] skip PR creation for non-implementer role=${job.roleId}`);
} else if (!needsWorktree) {
console.info(
`[worker] harness=${harness.alias} needsWorktree=false skipping push, logging published artifact`,
);
try {
await (db as unknown as { insert: (t: typeof convoyEvents) => { values: (v: unknown) => Promise<unknown> } })
.insert(convoyEvents)
.values({
workspaceId: job.workspaceId,
convoyId: job.convoyId,
briefId: job.briefId ?? null,
role: job.roleId,
actor: job.roleId,
eventType: "role_complete",
outcome: "published",
metadata: {
published: true,
skipPush: true,
convoySlug: ctx.convoySlug,
briefId: job.briefId ?? null,
},
} as never);
} catch (error) {
console.warn("[worker] published artifact log failed:", error);
}
} else if (worktreePath && worktreeBranch) {
try {
const rows = await (db as unknown as {
select: () => { from: (t: unknown) => { where: (c: unknown) => { limit: (n: number) => Promise<unknown[]>; orderBy?: unknown } } };
}).select().from(connectedRepos).where(eq(connectedRepos.workspaceId, job.workspaceId)).limit(1) as unknown as typeof connectedRepos.$inferSelect[];
let repoRow: typeof connectedRepos.$inferSelect | null = null;
if (Array.isArray(rows) && rows.length > 0) repoRow = rows[0] as typeof connectedRepos.$inferSelect;
if (!repoRow) {
console.info("[worker] skip PR creation: no connected repo for workspace");
} else {
const tokenEnvVar = (repoRow as { tokenEnvVar?: string | null }).tokenEnvVar;
const token = tokenEnvVar ? process.env[tokenEnvVar]?.trim() || process.env.GITHUB_TOKEN?.trim() : process.env.GITHUB_TOKEN?.trim();
const apiBaseUrl: string = (repoRow as { apiBaseUrl: string }).apiBaseUrl;
const owner: string = (repoRow as { owner: string }).owner;
const repo: string = (repoRow as { repoName: string }).repoName;
const forgeType: string = (repoRow as { forgeType: string }).forgeType;
const defaultBranch: string | null = (repoRow as { defaultBranch?: string | null }).defaultBranch ?? null;
const forge = {
createPullRequest: async (input: { base: string; head: string; title: string; body: string; draft: boolean }) => {
const url = `${apiBaseUrl.replace(/\/$/, "")}/repos/${owner}/${repo}/pulls`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(forgeType === "github"
? { Accept: "application/vnd.github+json" }
: { Accept: "application/json" }),
};
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(input),
});
const text = await res.text();
let body: unknown = null;
try {
body = text ? (JSON.parse(text) as unknown) : null;
} catch {
throw new Error(`Forge API returned non-JSON response (${res.status}).`);
}
if (!res.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof (body as { message: unknown }).message === "string"
? (body as { message: string }).message
: `Forge API error (${res.status})`;
throw new Error(message);
}
const htmlUrl =
typeof body === "object" && body !== null && "html_url" in body && typeof (body as { html_url: unknown }).html_url === "string"
? (body as { html_url: string }).html_url
: typeof body === "object" && body !== null && "url" in body && typeof (body as { url: unknown }).url === "string"
? (body as { url: string }).url
: `${apiBaseUrl.replace(/\/api\/v1$/, "")}/${owner}/${repo}/pulls/unknown`;
const number =
typeof body === "object" && body !== null && "number" in body && typeof (body as { number: unknown }).number === "number"
? (body as { number: number }).number
: 0;
return { url: htmlUrl, number };
},
};
const result = await openDraftPR({
monorepoRoot,
worktreePath,
branch: worktreeBranch,
convoySlug: ctx.convoySlug,
briefId: job.briefId ?? null,
convoyId: job.convoyId,
workspaceId: job.workspaceId,
forge,
baseBranch: defaultBranch ?? "main",
});
console.info(`[worker] draft PR created url=${result.url} number=${result.number}`);
try {
await (db as unknown as { insert: (t: typeof convoyEvents) => { values: (v: unknown) => Promise<unknown> } })
.insert(convoyEvents)
.values({
workspaceId: job.workspaceId,
convoyId: job.convoyId,
briefId: job.briefId ?? null,
role: job.roleId,
actor: job.roleId,
eventType: "role_complete",
outcome: "pr_created",
metadata: {
prUrl: result.url,
prNumber: result.number,
convoySlug: ctx.convoySlug,
briefId: job.briefId ?? null,
branch: worktreeBranch,
},
} as never);
} catch (e) {
console.warn("[worker] prUrl event insert failed:", e);
}
try {
const convoyRows = await (db as unknown as {
select: () => { from: (t: unknown) => { where: (c: unknown) => { limit: (n: number) => Promise<unknown[]> } } };
}).select().from(objects).where(eq(objects.id, job.convoyId)).limit(1) as unknown as Array<{ content: unknown }>;
const existing = convoyRows?.[0]?.content as Record<string, unknown> | null;
const nextContent = { ...(existing ?? {}), prUrl: result.url, prNumber: result.number };
await (db as unknown as { update: (t: typeof objects) => { set: (v: unknown) => { where: (c: unknown) => Promise<unknown> } } })
.update(objects)
.set({ content: nextContent, updatedAt: new Date() } as never)
.where(eq(objects.id, job.convoyId));
} catch (e) {
console.warn("[worker] prUrl content update failed:", e);
}
}
} catch (error) {
console.warn("[worker] draft PR failed:", error);
}
} else {
console.info("[worker] skip PR creation: no worktreePath");
}
}
} finally {
if (runId) {
try {

View file

@ -26,6 +26,19 @@ export type CheckpointSink = (input: {
filesTouched: WorkerCheckpointFileTouch[];
}) => Promise<void>;
export type OperatorNote = {
id: string;
delivery: "queued" | "direct";
body: string;
authorUserId: string;
};
export type OperatorNotesProvider = {
harnessAlias?: string;
poll: () => Promise<OperatorNote[]>;
markDelivered: (ids: string[]) => Promise<void>;
};
export type MinimalSessionOptions = {
resolvedModel: ResolvedModel;
resolvedAlias: string;
@ -41,6 +54,7 @@ export type MinimalSessionOptions = {
resumeFilesTouched?: WorkerCheckpointFileTouch[];
onCheckpoint?: CheckpointSink;
allowedCommands?: readonly string[] | null;
operatorNotes?: OperatorNotesProvider;
};
function sanitizeToolArgs(
@ -120,7 +134,7 @@ export function createMinimalSession(
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length);
const system = [
let systemBase = [
rolePrompt.trim(),
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
options.briefFiles?.length
@ -133,7 +147,39 @@ export function createMinimalSession(
buildResumeAppendix(resumeHistory),
].join("");
// Direct messages before next generateText: prepend to system tail
let directTail = "";
const harnessAlias = options.operatorNotes?.harnessAlias ?? options.resolvedAlias ?? "unknown";
if (options.operatorNotes) {
try {
const pending = await options.operatorNotes.poll();
const direct = pending.filter((n) => n.delivery === "direct");
const queuedBefore = pending.filter((n) => n.delivery === "queued");
if (direct.length > 0) {
const directText = direct.map((n) => `Direct message from ${n.authorUserId}: ${n.body}`).join("\n");
directTail = `\n\n## Direct operator messages\n${directText}`;
console.info(`[worker] direct message injected harness=${harnessAlias} count=${direct.length}`);
await options.operatorNotes.markDelivered(direct.map((n) => n.id));
}
// Also handle any queued that arrived before first step
if (queuedBefore.length > 0) {
for (const n of queuedBefore) {
const line = `Queued note from ${n.authorUserId}: ${n.body}`;
console.info(`[worker] queued note injected harness=${harnessAlias} ${line}`);
}
await options.operatorNotes.markDelivered(queuedBefore.map((n) => n.id));
// Append queued notes to system as well so model sees them early
const queuedText = queuedBefore.map((n) => `Queued note from ${n.authorUserId}: ${n.body}`).join("\n");
directTail += `\n\n## Queued operator notes\n${queuedText}`;
}
} catch (e) {
console.warn(`[worker] operator notes poll failed harness=${harnessAlias}`, e);
}
}
const system = systemBase + directTail;
const repoRoot = context.repoRoot;
const queuedNotesBuffer: string[] = [];
const recordStep = async (
toolName: WorkerCheckpointToolStep["tool"],
@ -162,6 +208,46 @@ export function createMinimalSession(
}
}
// Between tool rounds poll queued notes, append to toolHistory summary, mark delivered
if (options.operatorNotes) {
try {
const pending = await options.operatorNotes.poll();
const queued = pending.filter((n) => n.delivery === "queued");
const directMid = pending.filter((n) => n.delivery === "direct");
if (queued.length > 0) {
for (const n of queued) {
const line = `Queued note from ${n.authorUserId}: ${n.body}`;
queuedNotesBuffer.push(line);
console.info(`[worker] queued note injected harness=${harnessAlias} ${line}`);
// Synthetic checkpoint summary entry: push as extra tool history digest for visibility
// We append queued note to toolHistory as an extra ok entry so checkpoint summary shows it
// (using read_file tool as placeholder to satisfy schema)
toolHistory.push({
seq: toolCalls + 0.5,
tool: "read_file",
args: { path: `operator_note:${n.id.slice(0, 8)}` },
ok: true,
resultDigest: `queued:${hashContent(n.body).slice(0, 12)}`,
} as unknown as WorkerCheckpointToolStep);
}
await options.operatorNotes.markDelivered(queued.map((n) => n.id));
}
if (directMid.length > 0) {
const directText = directMid.map((n) => `Direct message from ${n.authorUserId}: ${n.body}`).join("\n");
console.info(`[worker] direct message injected harness=${harnessAlias} count=${directMid.length} mid-run`);
// Direct messages mid-run can't change current system; log and mark delivered for next run
// Append to buffer so summary reflects them
for (const n of directMid) {
queuedNotesBuffer.push(`Direct message from ${n.authorUserId}: ${n.body} (injected mid-run before next model call)`);
}
await options.operatorNotes.markDelivered(directMid.map((n) => n.id));
void directText;
}
} catch (e) {
console.warn(`[worker] operator notes poll between rounds failed harness=${harnessAlias}`, e);
}
}
if (options.onCheckpoint && toolCalls % checkpointEvery === 0) {
await options.onCheckpoint({
checkpointSeq: toolCalls,
@ -230,8 +316,12 @@ export function createMinimalSession(
},
});
let summary = result.text.trim() || "Session completed with no summary text.";
if (queuedNotesBuffer.length > 0) {
summary += `\n\n---\nOperator notes injected:\n${queuedNotesBuffer.join("\n")}`;
}
return {
summary: result.text.trim() || "Session completed with no summary text.",
summary,
toolCalls,
tokensTotal: result.usage?.totalTokens,
};

View file

@ -249,6 +249,8 @@ Shipped when a workspace enables pipeline integration:
| `gate-copilot` | `claude-planning` | premium |
| `chat-default` | workspace choice | — |
Harness for `role-doc-writer`: alias `doc-writer` (`harnessType: minimal`, `needsWorktree: false`, `allowedCommands: []` in `apps/worker/src/harness.ts:13`). Suggested second-brain preset is `role-doc-writer → qwen-coder-mlx (http://mac:8000/v1) + doc-writer`; seed via `harnesses.create { alias: "doc-writer", harnessType: "minimal", displayName: "Doc writer" }` then `harnesses.upsertRoute { roleId: "role-doc-writer", harnessProviderId }` if not pre-seeded.
Operators remap aliases in Settings; seed is not immutable.
## Convoy / brief integration

View file

@ -0,0 +1,22 @@
# Heavy research pipeline — source → claim → outline → publish
E2E fixture for `role-doc-writer` via harness `doc-writer` (`apps/worker/src/harness.ts:13`, `packages/shared/src/convoy-lifecycle/state-machine.ts:9`).
## Flow
1. **Source** — capture references or question under `docs/research/` or convoy brief.
2. **Claim** — `claim_task` assigns `role-doc-writer`; worker resolves model `qwen-coder-mlx` + harness `doc-writer`.
3. **Outline** — map claims to evidence, draft outline with `read_file`/`grep`.
4. **Publish** — write `docs/` or SOP file with `write_file` only, then `in_progress → doc_review` (automation or `role-doc-writer`), then `doc_review → published` via `human-gate-2` / `doc-approver`; finally `published → released` if promoted.
## Harness constraints
- `needsWorktree: false` — writes directly to `monorepoRoot`, no branch or `/tmp/echodo-worker-worktrees` worktree.
- `allowedCommands: []` — no `run_command`; fixture fails if the tool trace contains `run_command` or `pnpm`.
- Mirrors `pr_review → merged` for code: `doc_review → published` is the docs-only gate.
## Verify
- Worker trace shows only `read_file`, `grep`, `write_file`.
- Convoy event `role_complete` with `published: true` and Mission control shows `doc_review → published` by `doc-approver`.
- Counterpart template: `docs/templates/doc-sop.md`.

View file

@ -0,0 +1,45 @@
# Live E2E Dogfood Checklist — `Task-worker-live-e2e-dogfood.md` (A1 verify)
This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`. Mark the Task `done` only when every box below is true on your deploy (Coolify or local Redis).
## Env (document names only, no secrets)
- `ORCHESTRATOR_DRY_RUN=0` on orchestrator service
- `WORKER_DRY_RUN=0` on worker service
- `REDIS_URL` set on both (same `echodo:orchestrator:jobs` queue)
- `DATABASE_URL` `echodo` on both + web (migrations 0015+ applied)
- Workspace has `model_providers` alias + `role_model_routes role-implementer → qwen-coder` and `harness_providers alias: minimal` (or `doc-writer` for doc)
## Steps
1. Connected repo reachable from worker: `apps/worker` can `git clone`/`git push` the `repo_links` URL (or monorepo root when `WORKER_REPO_ROOT` unset).
2. Create or reconcile convoy with implementer brief: either `Plans → Start → Convoy` on a `ready` Task or `create_convoy` + `create_brief` via MCP `reconcile_from_files`.
3. Advance to `ready` (Gate 1 `human-gate-1` approve if `convoy_review`).
4. Worker logs show `dequeuing job` → `claimed backlog=... run=...` → `tools=...` → `complete_task` → `role_complete` `convoy_events.metadata.harnessAlias` present.
5. Settings → Runs (`/[workspaceSlug]/settings/runs`) shows row with `model_alias` + `tokensTotal` for that `runId`.
6. Mission Control detail `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` shows `role_complete` event within 5s (SSE `/api/fleet/events` or poll fallback).
7. (Preferred, after `Task-worker-draft-pr`) Draft PR exists with marker `echodo-convoy: <convoyId>` consumable by Epic 8 webhook; for doc tasks `doc_review → published` without PR but `published` visible.
## Record
- Convoy slug:
- Run id (`agent_runs.id`):
- `harnessAlias` / `modelAlias`:
- PR url (if code):
## Dry-run fallback still works
- With `DATABASE_URL` missing or `REDIS_URL` unset, `pnpm --filter @tasks/worker dev` logs `DRY-RUN mode — dequeue loop idle` and exits 0; `ORCHESTRATOR_DRY_RUN=1` logs dispatch without enqueue.
## Not this task
- Forge webhooks (Epic 8) — verified separately
- New worker features — use sibling tasks
## Links
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`
- Convoy: `.convoys/worker-closeout-docwriter.md`
- Worker closeout context: `.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md`
Fill convoy slug + run id above, then flip Task status `ready → done` and `exportBacklogItemToMarkdown` will persist it.

40
docs/templates/doc-sop.md vendored Normal file
View file

@ -0,0 +1,40 @@
---
kind: doc-sop
slug: "<doc-sop-slug>"
title: "<SOP title>"
status: draft
priority: P2
tenant_id: "<tenant-uuid-or-placeholder>"
updated_at: "<ISO-8601>"
---
# SOP: <SOP title>
One-line purpose of this SOP.
## Objective
What this SOP standardizes and when to use it.
## Prerequisites
- Inputs, tools, or access required
## Steps
1. Step one — action
2. Step two — action
3. Step three — action
## Verification
- [ ] Outcome check 1
- [ ] Outcome check 2
## Status
`draft → doc_review → published` via `human-gate-2` / `doc-approver` (`packages/shared/src/convoy-lifecycle/state-machine.ts:9`)
## Links
- Related: `docs/examples/research-pipeline.md`

View file

@ -28,6 +28,22 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
- **Lifecycle** — DB writes mirroring MCP `claim_task` / `complete_task`; `role_complete` convoy event on finish (includes `metadata.harnessAlias`)
- **Harnesses** — `resolveHarnessForJob` maps `job.harnessAlias` or `roleId` to `{ alias, needsWorktree, allowedCommands }`; `allowedCommands` is threaded to `runCommandTool` via `MinimalSession`. Brief 3 will promote this to DB-backed `harness_providers` / `role_harness_routes`.
### `role-doc-writer` via `doc-writer` example
```ts
// DB seed (or via tRPC harnesses.create / upsertRoute)
await harnesses.create({ alias: "doc-writer", displayName: "Doc writer", harnessType: "minimal" });
await harnesses.upsertRoute({ roleId: "role-doc-writer", harnessProviderId: docWriter.id });
// Job dispatch
{ roleId: "role-doc-writer", harnessAlias: "doc-writer" } // resolveHarnessForJob → { alias: "doc-writer", needsWorktree: false, allowedCommands: [] }
// Session trace: read_file / grep / write_file only — no run_command, no worktree under /tmp/echodo-worker-worktrees.
// Gate: in_progress → doc_review (automation or role-doc-writer), then doc_review → published via human-gate-2 / doc-approver.
```
See also `docs/examples/research-pipeline.md` (fixture) and `docs/templates/doc-sop.md` (`kind: doc-sop`).
## Env
| Variable | Default | Purpose |

View file

@ -0,0 +1,19 @@
CREATE TABLE "convoy_operator_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"convoy_id" uuid NOT NULL,
"brief_id" uuid,
"agent_run_id" uuid,
"delivery" varchar(20) NOT NULL,
"body" text NOT NULL,
"author_user_id" uuid NOT NULL,
"delivered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");

View file

@ -113,6 +113,13 @@
"when": 1787692015306,
"tag": "0015_curved_iron_fist",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787850000000,
"tag": "0016_noble_operator_notes",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,35 @@
import { index, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { agentRuns } from "./agent_runs";
import { objects } from "./objects";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const convoyOperatorMessages = pgTable(
"convoy_operator_messages",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
convoyId: uuid("convoy_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }),
agentRunId: uuid("agent_run_id").references(() => agentRuns.id, {
onDelete: "set null",
}),
delivery: varchar("delivery", { length: 20 }).notNull().$type<"queued" | "direct">(),
body: text("body").notNull(),
authorUserId: uuid("author_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceConvoyDeliveredIdx: index(
"convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx",
).on(table.workspaceId, table.convoyId, table.deliveredAt),
}),
);

View file

@ -19,3 +19,4 @@ export * from "./connected_repos";
export * from "./convoy_events";
export * from "./worker_checkpoints";
export * from "./harness_providers";
export * from "./convoy_operator_messages";

View file

@ -61,6 +61,8 @@ export const convoyObjectContentSchema = z.object({
planSlug: z.string().min(1).optional(),
epicSlug: z.string().min(1).optional(),
briefFiles: z.array(z.string()).optional(),
prUrl: z.string().url().optional(),
prNumber: z.number().int().optional(),
});
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;