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
181 changes: 179 additions & 2 deletions cmd/brainrot/cmd/task.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package cmd

import (
"errors"
"fmt"
"text/tabwriter"

"github.com/google/uuid"
"github.com/spf13/cobra"
)

Expand All @@ -20,7 +22,7 @@ type taskView struct {

var taskCmd = &cobra.Command{
Use: "task",
Short: "Read brainrot tasks.",
Short: "Read, create, rename, and dispatch task cards.",
}

var taskListCmd = &cobra.Command{
Expand Down Expand Up @@ -65,8 +67,183 @@ var taskGetCmd = &cobra.Command{
},
}

// task create — POST /api/v1/projects/{pid}/tasks
var (
tcProject string
tcTitle string
tcSummary string
tcSortOrder float64
)

var taskCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new task card in a project.",
RunE: func(cmd *cobra.Command, args []string) error {
if tcProject == "" {
return errors.New("--project is required")
}
if tcTitle == "" {
return errors.New("--title is required")
}
body := map[string]any{
"title": tcTitle,
"summary": tcSummary,
"sort_order": tcSortOrder,
}
var out taskView
if err := rt.cli.post("/api/v1/projects/"+tcProject+"/tasks", body, &out); err != nil {
return err
}
return emit(out, func(w *tabwriter.Writer) {
fmt.Fprintln(w, "FIELD\tVALUE")
fmt.Fprintf(w, "ID\t%s\n", out.ID)
fmt.Fprintf(w, "Project\t%s\n", out.ProjectID)
fmt.Fprintf(w, "Title\t%s\n", out.Title)
fmt.Fprintf(w, "Summary\t%s\n", out.Summary)
fmt.Fprintf(w, "Status\t%s\n", out.Status)
})
},
}

// task update — PATCH /api/v1/tasks/{id} with {title?, summary?}.
// The server returns 204; we re-GET so the caller sees the new state in one go.
var (
tuTitle string
tuSummary string
)

var taskUpdateCmd = &cobra.Command{
Use: "update <task-id>",
Short: "Rename or re-summarize a task card.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
titleSet := cmd.Flags().Changed("title")
summarySet := cmd.Flags().Changed("summary")
if !titleSet && !summarySet {
return errors.New("nothing to update (set --title and/or --summary)")
}
body := map[string]any{}
if titleSet {
body["title"] = tuTitle
}
if summarySet {
body["summary"] = tuSummary
}
if err := rt.cli.patch("/api/v1/tasks/"+args[0], body, nil); err != nil {
return err
}
var t taskView
if err := rt.cli.get("/api/v1/tasks/"+args[0], &t); err != nil {
return err
}
return emit(t, func(w *tabwriter.Writer) {
fmt.Fprintln(w, "FIELD\tVALUE")
fmt.Fprintf(w, "ID\t%s\n", t.ID)
fmt.Fprintf(w, "Title\t%s\n", t.Title)
fmt.Fprintf(w, "Summary\t%s\n", t.Summary)
fmt.Fprintf(w, "Status\t%s\n", t.Status)
})
},
}

// task dispatch — thin wrapper for "post a message on another card and
// trigger an agent run there." Same wire shape as `message append
// --mention-agent`; lives under `task` because the cross-card workflow is
// what agents are looking for in `brainrot task --help`.
var (
tdAgent string
tdContent string
tdContentFile string
tdContentStdin bool
)

var taskDispatchCmd = &cobra.Command{
Use: "dispatch <task-id>",
Short: "Post a message on a (possibly sibling) task card and dispatch an agent run there.",
Long: `Post a message on the named task card with the given agent mentioned —
the server enqueues a fresh run for that agent on that card.

WARNING: this is the cross-card equivalent of 'message append --mention-agent'.
Use ONLY for the initial dispatch of a sub-task. NEVER use as an
acknowledgment / thanks / final aggregation: it creates agent-to-agent loops
that bill you continuously.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if tdAgent == "" {
return errors.New("--agent is required")
}
// Catch the "I passed a handle instead of a UUID" case client-side.
// Server-side, internal/service/message.go silently drops mentions
// that don't parse as UUID — the POST would succeed with RunsEnqueued=0
// and the operator would think the dispatch worked.
if _, err := uuid.Parse(tdAgent); err != nil {
return fmt.Errorf("--agent must be a UUID, got %q (look up the agent's id with `brainrot agent list`)", tdAgent)
}
text, err := readContent(tdContent, tdContentFile, tdContentStdin)
if err != nil {
return err
}
body := map[string]any{
"content": map[string]any{
"text": text,
"mentions": []string{tdAgent},
},
}
var out struct {
Message messageView `json:"message"`
Runs []struct {
RunID string `json:"RunID"`
AgentID string `json:"AgentID"`
RuntimeID string `json:"RuntimeID"`
} `json:"runs"`
}
if err := rt.cli.post("/api/v1/tasks/"+args[0]+"/messages", body, &out); err != nil {
return err
}
for _, run := range out.Runs {
fmt.Fprintf(cmd.ErrOrStderr(), "note: dispatched run %s to agent %s on task %s\n",
run.RunID, run.AgentID, args[0])
}
// No run enqueued means the message landed but the server didn't act
// on the mention — usually the agent isn't a member of this card's
// workspace, or a run is already active. Fail loudly with a non-zero
// exit so callers (skills, scripts) don't proceed thinking work was
// dispatched.
if len(out.Runs) == 0 {
fmt.Fprintf(cmd.ErrOrStderr(),
"warning: no run was enqueued — agent %s may not be a member of this card's workspace, or a run is already active on task %s\n",
tdAgent, args[0])
return errors.New("dispatch produced no run; see stderr for likely causes")
}
return emit(out, func(w *tabwriter.Writer) {
fmt.Fprintln(w, "FIELD\tVALUE")
fmt.Fprintf(w, "MessageID\t%s\n", out.Message.ID)
fmt.Fprintf(w, "TaskCard\t%s\n", out.Message.TaskCardID)
fmt.Fprintf(w, "Created\t%s\n", out.Message.CreatedAt)
fmt.Fprintf(w, "RunsEnqueued\t%d\n", len(out.Runs))
for _, r := range out.Runs {
fmt.Fprintf(w, " run %s\t agent %s\n", r.RunID, r.AgentID)
}
})
},
}

func init() {
taskListCmd.Flags().String("project", "", "project UUID")
taskCmd.AddCommand(taskListCmd, taskGetCmd)

taskCreateCmd.Flags().StringVar(&tcProject, "project", "", "project UUID (required)")
taskCreateCmd.Flags().StringVar(&tcTitle, "title", "", "card title (required)")
taskCreateCmd.Flags().StringVar(&tcSummary, "summary", "", "card summary")
taskCreateCmd.Flags().Float64Var(&tcSortOrder, "sort-order", 0, "sort key within project (default 0)")

taskUpdateCmd.Flags().StringVar(&tuTitle, "title", "", "new title")
taskUpdateCmd.Flags().StringVar(&tuSummary, "summary", "", "new summary")

taskDispatchCmd.Flags().StringVar(&tdAgent, "agent", "", "agent UUID to dispatch to (required); enqueues a run on the target card")
taskDispatchCmd.Flags().StringVar(&tdContent, "content", "", `inline body (escapes \n \t)`)
taskDispatchCmd.Flags().StringVar(&tdContentFile, "content-file", "", "read body from file (required on Windows for non-ASCII)")
taskDispatchCmd.Flags().BoolVar(&tdContentStdin, "content-stdin", false, "read body from stdin (heredoc)")

taskCmd.AddCommand(taskListCmd, taskGetCmd, taskCreateCmd, taskUpdateCmd, taskDispatchCmd)
rootCmd.AddCommand(taskCmd)
}
5 changes: 3 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ func main() {

q := dbgen.New(pool)
wsSvc := service.NewWorkspace(pool, q)
rtSvc := service.NewRuntime(pool, q).WithWorkspace(wsSvc)
authSvc := service.NewAuth(pool, q)
rtSvc := service.NewRuntime(pool, q).WithWorkspace(wsSvc).WithAuth(authSvc)
agSvc := service.NewAgent(pool, q, wsSvc)
pjSvc := service.NewProject(pool, q, wsSvc)
tkSvc := service.NewTask(pool, q, wsSvc, pjSvc).WithBus(bus)
Expand All @@ -71,7 +72,7 @@ func main() {

deps := Deps{
Pool: pool,
Auth: service.NewAuth(pool, q),
Auth: authSvc,
Workspace: wsSvc,
Runtime: rtSvc,
Agent: agSvc,
Expand Down
13 changes: 10 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,15 @@ GET /api/v1/projects/{project_id}/tasks
→ 200 [TaskCard, ...]

PATCH /api/v1/tasks/{task_id}
{ "status": "open" | "in_progress" | "done" | "blocked" | "archived" }
{
"status": "open" | "in_progress" | "done" | "blocked" | "archived", // optional
"title": "<new title>", // optional; rejected if empty after trim
"summary": "<new summary>" // optional; "" is allowed (clears the field)
}
→ 204
// 三组字段都是可选;客户端只需要带要改的字段。status 与 title/summary
// 可以同时下发:服务端会先应用 status 再应用 title/summary。
// title/summary 走的 sqlc 是 UpdateTaskCardMeta,nil 字段保持原值。

POST /api/v1/tasks/{task_id}/cancel-run
→ 204 // 取消该任务卡当前活跃的 run(若有)
Expand Down Expand Up @@ -803,7 +810,7 @@ Cookie: brainrot_session=...
| `GET /projects/{id}/tasks` | 任意 ws 成员 | `403` | |
| `POST /projects/{id}/assets` | `owner` / `editor` | `403` | upload |
| `GET /projects/{id}/assets` | 任意 ws 成员 | `403` | |
| `PATCH /tasks/{id}` (status) | `owner` / `editor` | `403` | |
| `PATCH /tasks/{id}` (status / title / summary) | `owner` / `editor` | `403` | |
| `POST /tasks/{id}/cancel-run` | `owner` / `editor` | `403` | |
| `GET /tasks/{id}/messages` | 任意 ws 成员 | `403` | |
| `POST /tasks/{id}/messages` | `owner` / `editor` | `403` | 触发 agent |
Expand Down Expand Up @@ -1197,4 +1204,4 @@ The old `workspace_id` field on the response is set to the *viewing* workspace,

### Cross-workspace task execution

When a user in wsB @-mentions an installed agent (`agent.runtime_id` points to publisher Alice in wsA), the `agent_task_queue` row is created with `runtime_id = alice's runtime`, `workspace_id = wsB`. Alice's daemon claims the task by runtime_id (workspace-agnostic), runs Claude CLI in her local workdir (path includes `<wsB-short>_` prefix to avoid collisions with her own wsA tasks), and streams the result back via the standard `POST /api/daemon/runs/{run_id}/messages` path. The viewing workspace wsB sees the message live via task scope WS broadcast.
When a user in wsB @-mentions an installed agent (`agent.runtime_id` points to publisher Alice in wsA), the `agent_task_queue` row is created with `runtime_id = alice's runtime`, `workspace_id = wsB`. Alice's daemon claims the task by runtime_id (workspace-agnostic), runs Claude CLI in her local workdir (path is `<WorkdirRoot>/_projects/<projectID>/cards/<cardShort>`, keyed by projectID + cardShort — projectID scoping avoids collisions with her own wsA tasks rather than a workspace prefix), and streams the result back via the standard `POST /api/daemon/runs/{run_id}/messages` path. The viewing workspace wsB sees the message live via task scope WS broadcast.
22 changes: 22 additions & 0 deletions docs/daemon-workdir-layout.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Daemon workdir 布局:单 ws 时代 → marketplace 时代

> ⚠️ **已被取代(commit `8aacecc`,card 级共享 cwd 重构)。** 本文以下描述的
> `<wsShort>_<cardShort>/<agentShort>/workdir/` 布局、`.claude_home/` 隔离 HOME、
> `.brainrot-plugin/ --plugin-dir` 加载机制都是**历史**,已不再是当前行为。
>
> **当前布局(速查):**
> ```
> <WorkdirRoot>/_projects/<projectID>/cards/<cardShort>/ ← card 级,同卡所有 agent 共享
> ├── inputs/ (每次 run 清空)
> ├── .claude/skills|commands|agents/ (loose files,claude 启动自动发现,无 --plugin-dir)
> ├── CLAUDE.md (daemon 写的目录约定,不上传为 artifact)
> └── ...(agent 产出,跨 run、跨 agent 保留)
> <WorkdirRoot>/_projects/<projectID>/assets/ (只读项目素材池)
> <WorkdirRoot>/_projects/<projectID>/artifacts/<cardShort>-<title>/ (只读跨卡产物池,--add-dir 暴露)
> ```
> 要点:① 路径按 projectID + cardShort,不再含 ws 前缀(daemon 本身就 per-workspace);
> ② 同一张卡的所有 agent **共享一个 cwd**,跨 agent 的 `--resume` 隔离由 claude 自己的
> per-session-id jsonl 提供,不再靠 per-agent 目录;③ 不再有隔离 HOME(操作者真 `~/.claude`
> 直接继承);④ 老 session jsonl 不迁移(硬切),升级后每张卡首跑开新 session。
> 代码见 `internal/daemon/workdir.go` 的 `prepareWorkdir`、`artifacts.go`、`workdir_gc.go`。
>
> 以下原文保留作为**历史记录** —— 它解释了旧布局以及那次 `--resume` 事故的来龙去脉。

> 解释 daemon 把每个 task run 跑在哪个目录、为什么改了路径前缀、跑别人 marketplace 上的 agent 时具体在哪台机器上的哪个目录工作。
>
> 写本文的直接动因是一次"@agent 不回我"的事故:跑了几天的 task card 一夜之间 `--resume` 失败 ——
Expand Down
75 changes: 72 additions & 3 deletions internal/daemon/artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,36 @@
// <cardShort> if the sanitized title is empty). <filename> retains any
// workdir-relative subpath the agent wrote (e.g. "src/foo.go" stays).
//
// This pool is a separate tree from the agents' live cwds
// (<WorkdirRoot>/_projects/<projectID>/cards/<cardShort>/ — see prepareWorkdir).
// The pool is a read-only CACHE exposed to the agent via --add-dir so a card
// can see the outputs of every other card in the project (and its own prior
// outputs). The agent's actual outputs are written to its cwd and uploaded by
// scanNewFiles; reconcile never touches the cwd tree.
//
// Active-card protection (the data-loss guard): a card that is CURRENTLY
// running on this daemon (in d.activeCards) is a live working copy. Its pooled
// files may be FRESH outputs the agent just produced but hasn't uploaded yet,
// so the manifest (a server snapshot) won't list them. We therefore SKIP THE
// DELETE/PRUNE pass for active cards — but we do NOT skip the download pass:
// downloads are additive and idempotent, and they are exactly what lets a card
// re-homed to a NEW daemon (no local pooled copy) fetch its own prior outputs.
// Deletes are the only data-loss vector; downloads are always safe.
//
// Note the asymmetry: a sibling card B that just FINISHED on this daemon but
// hasn't uploaded yet is no longer in activeCards, so reconcile WILL prune B's
// not-yet-in-manifest pooled files. That's correct — B's real outputs live in
// B's cwd (cards/<B8>/) and were uploaded by scanNewFiles before B left
// activeCards; the pool is just a cache that re-aligns to server truth.
//
// Concurrency: per-project lock acquired on the artifacts directory using
// the same flock-based helper as the assets pool. Two reconciles for the
// same project on different cards will serialize.
// the same flock-based helper as the per-card cwd lock. The lock target here
// (the artifacts root) is intentionally DISJOINT from prepareWorkdir's
// per-card lock (cards/<cardShort>/): the lock registry in workdir_{unix,
// windows}.go is a process-global, NON-re-entrant map keyed by path, so these
// two lock sites must never share a path or they would clobber each other's
// registry entry. Two reconciles for the same project on different cards
// serialize on this root lock.
//
// Failures are non-fatal at the call site: the caller logs and continues
// with whatever made it onto disk. Partial state is fine; the next claim
Expand Down Expand Up @@ -63,10 +90,35 @@
want[dir][ar.Filename] = ar
}

// activePrefixes is the set of <card8> prefixes for cards this daemon is
// currently running. We match pooled dir names against these to skip the
// delete pass for live working copies. We match by prefix (not by a
// manifest-derived dirName→UUID map) on purpose: an active card may have
// ZERO manifest entries — that is precisely the "fresh outputs not yet
// uploaded" case D2 must protect — so the manifest cannot be the source of
// the dir→UUID mapping. Prefix collision (two cards sharing the first 8
// hex) only ever OVER-protects a cache dir (one skipped prune, self-heals
// next reconcile); it never causes data loss.
activePrefixes := map[string]struct{}{}
d.activeCards.Range(func(k, _ any) bool {
if id, ok := k.(uuid.UUID); ok {
activePrefixes[id.String()[:8]] = struct{}{}
}
return true
})
isActiveDir := func(name string) bool {
if len(name) < 8 {
return false
}
_, ok := activePrefixes[name[:8]]
return ok
}

// Pass 1: walk every per-card subdirectory and delete files that
// either (a) aren't in the manifest at all (card excluded the
// artifact, or the card was deleted) or (b) have a stale sha256
// (artifact replaced in a later run).
// (artifact replaced in a later run). Active cards are skipped here
// (see the active-card protection note above).
entries, err := os.ReadDir(artifactsDir)
if err != nil {
return "", fmt.Errorf("read project artifacts: %w", err)
Expand All @@ -81,6 +133,23 @@
_ = os.Remove(filepath.Join(artifactsDir, name))
continue
}
// Skip deletes for a card currently running on this daemon: its
// pooled files may be fresh, not-yet-uploaded outputs. We still let
// pass 2 download anything missing (so a re-homed card can fetch its
// own prior outputs), but drop already-current entries from `want`
// here so pass 2 does not redundantly re-download files we hold.
if isActiveDir(name) {
cardWant := want[name]
for fn := range cardWant {
abs := filepath.Join(artifactsDir, name, filepath.FromSlash(fn))
if got := readSidecar(filepath.Dir(abs), filepath.Base(abs)); got != "" {
if got == cardWant[fn].Sha256 {
delete(cardWant, fn)
}
}
}
continue
}
cardDir := filepath.Join(artifactsDir, name)
cardWant := want[name]
if cardWant == nil {
Expand Down Expand Up @@ -190,16 +259,16 @@
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, h), rc); err != nil {
tmp.Close()
os.Remove(tmpName)

Check failure on line 262 in internal/daemon/artifacts.go

View workflow job for this annotation

GitHub Actions / build

Error return value of `os.Remove` is not checked (errcheck)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)

Check failure on line 266 in internal/daemon/artifacts.go

View workflow job for this annotation

GitHub Actions / build

Error return value of `os.Remove` is not checked (errcheck)
return err
}
got := hex.EncodeToString(h.Sum(nil))
if ref.Sha256 != "" && got != ref.Sha256 {
os.Remove(tmpName)

Check failure on line 271 in internal/daemon/artifacts.go

View workflow job for this annotation

GitHub Actions / build

Error return value of `os.Remove` is not checked (errcheck)
return fmt.Errorf("sha256 mismatch (manifest=%s, got=%s)", ref.Sha256, got)
}
if err := os.Rename(tmpName, dst); err != nil {
Expand Down
Loading
Loading