diff --git a/.patchdock/config.yml b/.patchdock/config.yml index 39b9b6d..56272a1 100644 --- a/.patchdock/config.yml +++ b/.patchdock/config.yml @@ -31,7 +31,11 @@ credentials: CODEX_HOME: /codex-auth # Which filepath drives each stage. +# +# Currently pointed at the mock agents in mock/, which sleep and return canned +# output instead of calling a model. Swap back to planner.ts / executor.ts / +# reviewer.ts to use Codex again. stages: - planner: planner.ts - executor: executor.ts - reviewer: reviewer.ts + planner: mock/planner.ts + executor: mock/executor.ts + reviewer: mock/reviewer.ts diff --git a/.patchdock/mock/executor.ts b/.patchdock/mock/executor.ts new file mode 100644 index 0000000..1990abf --- /dev/null +++ b/.patchdock/mock/executor.ts @@ -0,0 +1,73 @@ +import { defineExecutor } from "@patchdock/sdk"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { jitter, pace } from "./lib.ts"; + +const MIN_SECONDS = 45; +const MAX_SECONDS = 60; +const MARKER_FILE = "MOCK_RUN.md"; + +export default defineExecutor({ + async run(ctx, input) { + const workspace = ctx.paths.workspace; + if (workspace === undefined) { + throw new Error("executor stage started without a workspace mount"); + } + + const total = jitter(MIN_SECONDS, MAX_SECONDS); + + await pace(ctx, total * 0.6, [ + { source: "agent", event: "process_started" }, + { source: "agent", event: "session_started" }, + { + source: "agent", + event: "command_completed", + command: "git status --short", + }, + { + source: "agent", + event: "tool_call_completed", + server: "fs", + tool: "list_dir", + }, + ]); + + const rejection = input.reviews.at(-1); + const body = [ + "# Mock run", + "", + `- run: ${ctx.runId}`, + `- attempt: ${ctx.attempt} of ${ctx.maxAttempts}`, + `- plan: ${input.plan.summary}`, + `- written: ${new Date().toISOString()}`, + "", + rejection === undefined + ? "First attempt." + : `Retrying after review feedback: ${rejection.feedback ?? rejection.summary}`, + "", + "This file was written by the mock executor. No model was called.", + ].join("\n"); + + await writeFile(join(workspace, MARKER_FILE), `${body}\n`, "utf8"); + + ctx.log({ + source: "agent", + event: "file_change_completed", + changes: [{ path: MARKER_FILE }], + }); + + await pace(ctx, total * 0.4, [ + { + source: "agent", + event: "command_completed", + command: "git diff --stat", + }, + { source: "agent", event: "turn_completed" }, + ]); + + return { + status: "success", + notes: `Mock executor wrote ${MARKER_FILE} on attempt ${ctx.attempt}.`, + }; + }, +}); diff --git a/.patchdock/mock/lib.ts b/.patchdock/mock/lib.ts new file mode 100644 index 0000000..740d47c --- /dev/null +++ b/.patchdock/mock/lib.ts @@ -0,0 +1,29 @@ +import type { StageContext, StageLogEvent } from "@patchdock/sdk"; + +export function jitter(minSeconds: number, maxSeconds: number): number { + return (minSeconds + Math.random() * (maxSeconds - minSeconds)) * 1000; +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Spreads steps evenly across totalMs so the live activity feed has something +// to render while the stage pretends to work. +export async function pace( + ctx: StageContext, + totalMs: number, + steps: StageLogEvent[], +): Promise { + const slice = totalMs / steps.length; + + for (const step of steps) { + ctx.log(step); + await sleep(slice); + } +} + +export function firstLine(text: string): string { + const line = text.split("\n").find((candidate) => candidate.trim() !== ""); + return line?.trim() ?? "untitled task"; +} diff --git a/.patchdock/mock/planner.ts b/.patchdock/mock/planner.ts new file mode 100644 index 0000000..7e2e539 --- /dev/null +++ b/.patchdock/mock/planner.ts @@ -0,0 +1,43 @@ +import { definePlanner } from "@patchdock/sdk"; +import { firstLine, jitter, pace } from "./lib.ts"; + +const MIN_SECONDS = 15; +const MAX_SECONDS = 20; + +export default definePlanner({ + async run(ctx, input) { + const title = firstLine(input.task.description); + + await pace(ctx, jitter(MIN_SECONDS, MAX_SECONDS), [ + { source: "agent", event: "process_started" }, + { source: "agent", event: "session_started" }, + { + source: "agent", + event: "command_completed", + command: "rg -n 'TODO' .", + }, + { + source: "agent", + event: "tool_call_completed", + server: "fs", + tool: "read_file", + }, + { source: "agent", event: "turn_completed" }, + ]); + + return { + summary: `Mock plan: ${title}`, + body: [ + `Mock plan for run ${ctx.runId}.`, + "", + `Request: ${title}`, + "", + "Steps:", + "1. Write a marker file into the workspace.", + "2. Hand the diff to the reviewer.", + "", + "No model was called to produce this plan.", + ].join("\n"), + }; + }, +}); diff --git a/.patchdock/mock/reviewer.ts b/.patchdock/mock/reviewer.ts new file mode 100644 index 0000000..5506047 --- /dev/null +++ b/.patchdock/mock/reviewer.ts @@ -0,0 +1,48 @@ +import { defineReviewer } from "@patchdock/sdk"; +import { jitter, pace } from "./lib.ts"; + +const MIN_SECONDS = 10; +const MAX_SECONDS = 15; +const REJECT_CHANCE = 0.25; + +export default defineReviewer({ + async run(ctx, input) { + const bytes = input.patch.length; + + if (input.patch.trim() === "") { + ctx.log({ + source: "agent", + event: "message", + level: "warn", + message: "reviewer received an empty patch", + }); + } + + await pace(ctx, jitter(MIN_SECONDS, MAX_SECONDS), [ + { source: "agent", event: "process_started" }, + { source: "agent", event: "session_started" }, + { + source: "agent", + event: "command_completed", + command: `git diff --stat # ${bytes} bytes`, + }, + { source: "agent", event: "turn_completed" }, + ]); + + if (Math.random() < REJECT_CHANCE) { + return { + decision: "reject", + summary: `Mock reviewer rejected attempt ${ctx.attempt}`, + feedback: [ + "Rejected by the mock reviewer on a coin flip, not on the diff.", + `Attempt ${ctx.attempt} of ${ctx.maxAttempts}, patch was ${bytes} bytes.`, + ].join(" "), + }; + } + + return { + decision: "accept", + summary: `Mock reviewer accepted attempt ${ctx.attempt} (${bytes}-byte patch)`, + }; + }, +}); diff --git a/.patchdock/tsconfig.json b/.patchdock/tsconfig.json new file mode 100644 index 0000000..a91da6a --- /dev/null +++ b/.patchdock/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts", "mock"] +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e85889..a78ff49 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -198,7 +198,7 @@ repository but cannot change it; the executor may only change the disposable workspace clone; the reviewer can inspect the changed workspace but not tamper with it. Mount targets are checked for collisions, and credential environment variables may not shadow the reserved `PATCHDOCK_*` variables -the runtime injects (stage name, task ID, agent file, token budget, attempt +the runtime injects (stage name, run ID, agent file, token budget, attempt counters). ### Audit log diff --git a/Makefile b/Makefile index b9cb273..f81e024 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Rebuild and install the global patchdock binary (into $(go env GOPATH)/bin). .PHONY: install install: - go install . - @echo "installed: $$(command -v patchdock)" + go install ./... + @echo "patchdock has been installed" diff --git a/README.md b/README.md index 98a370a..7fb6588 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ container: token_budget: 100000 retries: - max: 2 + max: 3 ``` The container timeout is a hard wall-clock limit for each stage. The token @@ -177,17 +177,17 @@ Opens the terminal interface directly on the live dashboard. ### Submit a detached task ```console -dock -d "Update the API error handling" +dock "Update the API error handling" run-4e6b30262e44 ``` -Queues the task, starts the daemon on demand if necessary, prints the run ID, -and exits without opening the terminal interface. +Passing an inline prompt queues the task, starts the daemon on demand if +necessary, prints the run ID, and exits without opening the terminal interface. Use `--repo` to target another repository: ```bash -dock --repo ../another-project -d "Add request validation" +dock --repo ../another-project "Add request validation" ``` ### Control the daemon diff --git a/internal/auditlog/logger.go b/internal/auditlog/logger.go index d8231ab..74b6448 100644 --- a/internal/auditlog/logger.go +++ b/internal/auditlog/logger.go @@ -15,14 +15,14 @@ const ( failedOutputFile = "failed-output.json" ) +// Logger owns the audit output of one run; runID names its directory. type Logger struct { - LogID string // still don't know whether it's the best way to indicate, but since we have logger per pipeline, it can work LogDir string logStreamFile *os.File } -func New(id string, dir string) (*Logger, error) { - logDir := filepath.Join(dir, "logs", id) +func New(runID string, dir string) (*Logger, error) { + logDir := filepath.Join(dir, "logs", runID) if err := os.MkdirAll(logDir, 0o755); err != nil { return nil, fmt.Errorf("failed creating log directory: %w", err) @@ -35,8 +35,7 @@ func New(id string, dir string) (*Logger, error) { } return &Logger{ - LogDir: filepath.Join(dir, "logs", id), - LogID: id, + LogDir: logDir, logStreamFile: file, }, nil } diff --git a/internal/auditlog/render.go b/internal/auditlog/render.go index b14495d..e763f08 100644 --- a/internal/auditlog/render.go +++ b/internal/auditlog/render.go @@ -11,14 +11,14 @@ func renderRun(rec *Record) []byte { var b strings.Builder fmt.Fprintf(&b, "# %s · %s · %s · %s\n\n", - rec.Task.ID, outcomeWord(rec), utils.Plural(len(rec.Attempts), "attempt"), rec.Duration) + rec.RunID, outcomeWord(rec), utils.Plural(len(rec.Attempts), "attempt"), rec.Duration) if title := strings.TrimSpace(rec.Task.Title); title != "" { fmt.Fprintf(&b, "**%s**\n\n", title) } fmt.Fprintf(&b, "**Task:** %s\n\n", strings.TrimSpace(rec.Task.Description)) - if rec.Plan.ID != "" { + if rec.Plan.Summary != "" { b.WriteString("## Plan\n\n") fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(rec.Plan.Summary)) if body := strings.TrimSpace(rec.Plan.Body); body != "" { diff --git a/internal/cli/root.go b/internal/cli/root.go index e65ccb4..9a5462d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,10 +20,7 @@ const ( exitNoDaemon = 3 ) -var ( - rootDetach bool - rootRepo string -) +var rootRepo string var rootCmd = &cobra.Command{ Use: "dock [prompt]", @@ -42,16 +39,12 @@ var rootCmd = &cobra.Command{ if len(args) == 1 { return submitDetached(ctx, rootRepo, args[0]) } - if rootDetach { - return errors.New(`--detach needs an inline prompt: dock -d "…"`) - } return openApp(ctx, rootRepo, false) }, } func init() { - rootCmd.Flags().BoolVarP(&rootDetach, "detach", "d", false, "queue the inline prompt and print its run id") rootCmd.Flags().StringVar(&rootRepo, "repo", "", "target a repo other than the current directory") } diff --git a/internal/config/config.go b/internal/config/config.go index 12f3b1e..680da26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,9 @@ package config -import "github.com/HJyup/patchdock/internal/types" +import ( + "github.com/HJyup/patchdock/internal/types" + "github.com/HJyup/patchdock/internal/utils" +) type Config struct { Namespace string `yaml:"name_space"` @@ -22,8 +25,8 @@ type Credential struct { } type Container struct { - Timeout Duration `yaml:"timeout"` - TokenBudget int `yaml:"token_budget"` + Timeout utils.Duration `yaml:"timeout"` + TokenBudget int `yaml:"token_budget"` } type Retries struct { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 073d841..b85e330 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,9 +1,13 @@ package config -import "time" +import ( + "time" + + "github.com/HJyup/patchdock/internal/utils" +) const ( - DefaultTimeout = Duration(10 * time.Minute) + DefaultTimeout = utils.Duration(10 * time.Minute) DefaultTokenBudget = 100000 DefaultRetriesMax = 3 DefaultBranchPrefix = "patchdock" diff --git a/internal/config/duration.go b/internal/config/duration.go deleted file mode 100644 index 9303e89..0000000 --- a/internal/config/duration.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -import ( - "fmt" - "time" - - "gopkg.in/yaml.v3" -) - -// Duration wraps time.Duration so it can be read from YAML as a human string -// like "10m" or "90s". yaml.v3 cannot do this on time.Duration directly -type Duration time.Duration - -func (d *Duration) UnmarshalYAML(value *yaml.Node) error { - if value.Kind != yaml.ScalarNode { - return fmt.Errorf("duration must be a string like \"10m\" or 0") - } - - if value.Value == "0" { - *d = 0 - return nil - } - - parsed, err := time.ParseDuration(value.Value) - if err != nil { - return fmt.Errorf("invalid duration %q: %w", value.Value, err) - } - - *d = Duration(parsed) - return nil -} - -func (d Duration) MarshalYAML() (any, error) { - return time.Duration(d).String(), nil -} - -func (d Duration) Duration() time.Duration { - return time.Duration(d) -} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 4b19751..10d0dae 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/HJyup/patchdock/internal/types" + "github.com/HJyup/patchdock/internal/utils" ) const namespaceError = "config.name_space: must be lowercase letters or digits, separated by '.', '_' or '-'" @@ -156,7 +157,7 @@ func TestValidateFieldErrors(t *testing.T) { }, { name: "negative timeout", - mutate: func(c *Config) { c.Container.Timeout = Duration(-1) }, + mutate: func(c *Config) { c.Container.Timeout = utils.Duration(-1) }, want: "config.container.timeout: must be >= 0", }, { diff --git a/internal/daemon/api/state.go b/internal/daemon/api/state.go index a22f3bd..9522cca 100644 --- a/internal/daemon/api/state.go +++ b/internal/daemon/api/state.go @@ -51,7 +51,6 @@ func StatusForStage(stage types.StageName) Status { // Run is the daemon's record of one queued or running task type Run struct { ID string `json:"id"` - TaskID string `json:"task_id"` Repo string `json:"repo"` Title string `json:"title"` Status Status `json:"status"` diff --git a/internal/daemon/client/client.go b/internal/daemon/client/client.go index 1675643..97c329f 100644 --- a/internal/daemon/client/client.go +++ b/internal/daemon/client/client.go @@ -181,6 +181,7 @@ func (c *Client) do(ctx context.Context, method, path string, in, out any) error if err != nil { return DeamonError(err) } + defer resp.Body.Close() if err := checkStatus(resp); err != nil { return err diff --git a/internal/daemon/config/config.go b/internal/daemon/config/config.go new file mode 100644 index 0000000..3afb45b --- /dev/null +++ b/internal/daemon/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/HJyup/patchdock/internal/utils" +) + +const ( + DefaultMaxContainers = 3 + DefaultRetention = utils.Duration(15 * time.Minute) +) + +type Config struct { + MaxContainers int `json:"max_containers"` + Retention utils.Duration `json:"retention"` +} + +func Defaults() Config { + return Config{ + MaxContainers: DefaultMaxContainers, + Retention: DefaultRetention, + } +} + +func Load(path string) (*Config, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return create(path) + } + if err != nil { + return nil, fmt.Errorf("open config %s: %w", path, err) + } + defer file.Close() + + cfg := Defaults() + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&cfg); err != nil { + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("config %s is empty: delete it to recreate it with defaults", path) + } + return nil, fmt.Errorf("decode config %s: %w", path, err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validate config %s: %w", path, err) + } + + return &cfg, nil +} + +func create(path string) (*Config, error) { + cfg := Defaults() + + encoded, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, fmt.Errorf("encode config %s: %w", path, err) + } + encoded = append(encoded, '\n') + + if err := os.WriteFile(path, encoded, 0o600); err != nil { + return nil, fmt.Errorf("write config %s: %w", path, err) + } + + return &cfg, nil +} + +func (c *Config) Validate() error { + var errs []error + + if c.MaxContainers < 1 { + errs = append(errs, errors.New("config.max_containers: must be >= 1")) + } + if c.Retention <= 0 { + errs = append(errs, errors.New("config.retention: must be > 0")) + } + + return errors.Join(errs...) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3aad7fd..063fe22 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -12,7 +12,9 @@ import ( "time" "github.com/HJyup/patchdock/internal/daemon/broker" + "github.com/HJyup/patchdock/internal/daemon/config" "github.com/HJyup/patchdock/internal/daemon/queue" + "github.com/HJyup/patchdock/internal/docker" "github.com/HJyup/patchdock/internal/lock" "github.com/HJyup/patchdock/internal/runtimedir" "github.com/HJyup/patchdock/internal/transport" @@ -49,7 +51,20 @@ func RunServer(ctx context.Context, dir runtimedir.Dir) error { } defer listener.Close() - q := queue.New(ctx, queue.Config{Runner: runPipeline, Retention: 15 * time.Minute}) + cfg, err := config.Load(dir.Config()) + if err != nil { + return err + } + + // Constructing the client does not dial the daemon — connectivity failures + // still surface per run, where they name the stage that hit them. + cli, err := docker.NewClient() + if err != nil { + return fmt.Errorf("connect to docker: %w", err) + } + defer cli.Close() + + q := queue.New(ctx, queue.Config{Runner: pipelineRunner(cli), Retention: cfg.Retention.Duration(), MaxContainers: cfg.MaxContainers}) ch := q.Snaps() b := broker.New(ch) diff --git a/internal/daemon/queue/queue.go b/internal/daemon/queue/queue.go index e84387d..1ccff12 100644 --- a/internal/daemon/queue/queue.go +++ b/internal/daemon/queue/queue.go @@ -23,8 +23,9 @@ var ( ) type Config struct { - Runner Runner - Retention time.Duration + Runner Runner + Retention time.Duration + MaxContainers int } type run struct { @@ -32,14 +33,20 @@ type run struct { task types.Task } +type queuedTasks struct { + ctx context.Context + runID string +} + type Queue struct { inbox chan event snaps chan api.Snapshot runner Runner // defines retention policy for finilised runs - retention time.Duration - ctx context.Context + retention time.Duration + maxContainers int + ctx context.Context runs map[string]*run // define all nesseary context cancel function so it's easy to cancel certain runs @@ -47,13 +54,17 @@ type Queue struct { // cloning runs are the most expensive operation in the Queue. Dirty will guard of cloning up-to-date data dirty bool + + // scheduler implementation arrays + waiting []queuedTasks } func New(ctx context.Context, cfg Config) *Queue { return &Queue{ - inbox: make(chan event, inboxSize), - snaps: make(chan api.Snapshot, 1), - runner: cfg.Runner, + inbox: make(chan event, inboxSize), + snaps: make(chan api.Snapshot, 1), + runner: cfg.Runner, + maxContainers: cfg.MaxContainers, retention: cfg.Retention, ctx: ctx, @@ -78,6 +89,7 @@ func (q *Queue) Run() { case <-ticker.C: q.evict() + q.schedule() if q.dirty { q.publish() @@ -149,7 +161,6 @@ func (q *Queue) add(e addEvent) { task: e.task, state: &api.Run{ ID: id, - TaskID: e.task.ID, Repo: e.repo, Title: utils.FirstLine(e.task.Description), Status: api.StatusQueued, @@ -160,22 +171,10 @@ func (q *Queue) add(e addEvent) { q.runs[id] = r q.dirty = true - // TODO: This is where the scheduler will come into place - // Right now it's sequential - e.res <- id q.cancels[r.state.ID] = cancel - now := time.Now() - r.state.Status = api.StatusStarted - r.state.StartedAt = &now - q.dirty = true - - go q.execute(ctx, RunSpec{ - RunID: r.state.ID, - Repo: r.state.Repo, - Task: r.task, - }) + q.waiting = append(q.waiting, queuedTasks{ctx: ctx, runID: id}) } func (q *Queue) cancel(e cancelEvent) { @@ -215,6 +214,17 @@ func (q *Queue) stage(e stageEvent) { q.dirty = true } +func (q *Queue) active() int { + n := 0 + for _, r := range q.runs { + if r.state.StartedAt != nil && !api.IsFinilised(r.state.Status) { + n++ + } + } + + return n +} + func (q *Queue) activity(e activityEvent) { r, ok := q.runs[e.runID] if !ok || api.IsFinilised(r.state.Status) { @@ -266,10 +276,42 @@ func (q *Queue) done(e doneEvent) { } } - delete(q.cancels, e.runID) + if cancel, ok := q.cancels[e.runID]; ok { + cancel() + delete(q.cancels, e.runID) + } + q.dirty = true } +func (q *Queue) schedule() { + for len(q.waiting) > 0 && q.active() < q.maxContainers { + queued := q.waiting[0] + q.waiting = q.waiting[1:] + + r, ok := q.runs[queued.runID] + if !ok || api.IsFinilised(r.state.Status) { + continue + } + + if queued.ctx.Err() != nil { + q.done(doneEvent{runID: queued.runID, cancelled: true}) + continue + } + + now := time.Now() + r.state.Status = api.StatusStarted + r.state.StartedAt = &now + q.dirty = true + + go q.execute(queued.ctx, RunSpec{ + RunID: r.state.ID, + Repo: r.state.Repo, + Task: r.task, + }) + } +} + func (q *Queue) execute(ctx context.Context, spec RunSpec) { out, err := q.runner(ctx, spec, &reporter{queue: q, runID: spec.RunID}) event := doneEvent{ diff --git a/internal/daemon/runner.go b/internal/daemon/runner.go index 89a6322..0f3b1ba 100644 --- a/internal/daemon/runner.go +++ b/internal/daemon/runner.go @@ -19,7 +19,13 @@ import ( const patchdockDir = ".patchdock" -func runPipeline(ctx context.Context, spec queue.RunSpec, rep queue.Reporter) (queue.Outcome, error) { +func pipelineRunner(cli *docker.Client) queue.Runner { + return func(ctx context.Context, spec queue.RunSpec, rep queue.Reporter) (queue.Outcome, error) { + return runPipeline(ctx, cli, spec, rep) + } +} + +func runPipeline(ctx context.Context, cli *docker.Client, spec queue.RunSpec, rep queue.Reporter) (queue.Outcome, error) { dir := filepath.Join(spec.Repo, patchdockDir) if _, err := os.Stat(dir); err != nil { @@ -34,12 +40,6 @@ func runPipeline(ctx context.Context, spec queue.RunSpec, rep queue.Reporter) (q return queue.Outcome{}, err } - cli, err := docker.NewClient() - if err != nil { - return queue.Outcome{}, fmt.Errorf("connect to docker: %w. Is the Docker daemon running", err) - } - defer cli.Close() - logger, err := auditlog.New(spec.RunID, dir) if err != nil { return queue.Outcome{}, fmt.Errorf("open audit log: %w", err) @@ -63,6 +63,7 @@ func runPipeline(ctx context.Context, spec queue.RunSpec, rep queue.Reporter) (q } stages := stage.NewRunner(cli, stage.RunnerOptions{ + RunID: spec.RunID, ImageTag: imageTag, PatchdockDir: dir, LogWriter: logger, @@ -71,7 +72,7 @@ func runPipeline(ctx context.Context, spec queue.RunSpec, rep queue.Reporter) (q OnActivity: rep.StageActivity, }) - out, err := pipeline.New(cfg, spec.Repo, stages, logger, rep).Run(ctx, spec.Task) + out, err := pipeline.New(cfg, spec.RunID, spec.Repo, stages, logger, rep).Run(ctx, spec.Task) if err != nil { return queue.Outcome{}, fmt.Errorf("%w. Check %s", err, logger.LogDir) } diff --git a/internal/docker/build.go b/internal/docker/build.go index 93a3c51..eb1910b 100644 --- a/internal/docker/build.go +++ b/internal/docker/build.go @@ -14,9 +14,6 @@ import ( type buildOutput struct { Stream string `json:"stream"` Error string `json:"error"` - Aux struct { - ID string `json:"ID"` - } `json:"aux"` } // build closes both channels when the build completes, whether it succeeded or not. @@ -56,7 +53,6 @@ func build(ctx context.Context, cli *client.Client, spec BuildSpec) (<-chan LogL } func streamBuildLogs(body io.Reader, logs chan LogLine, result chan BuildResult) { - var imageID string decoder := json.NewDecoder(body) for { @@ -77,14 +73,10 @@ func streamBuildLogs(body io.Reader, logs chan LogLine, result chan BuildResult) return } - if out.Aux.ID != "" { - imageID = out.Aux.ID - } - if out.Stream != "" { logs <- LogLine{Text: out.Stream} } } - result <- BuildResult{ImageID: imageID} + result <- BuildResult{} } diff --git a/internal/docker/build_test.go b/internal/docker/build_test.go index 8daf60e..595066b 100644 --- a/internal/docker/build_test.go +++ b/internal/docker/build_test.go @@ -62,10 +62,6 @@ func TestBuildTagsImage(t *testing.T) { if r.Err != nil { t.Fatalf("build failed: %v", r.Err) } - if r.ImageID == "" { - t.Error("expected a non-empty ImageID from the aux stream") - } - // The point of the test: the tag must be findable on the daemon. found, err := c.ImageExists(ctx, imageTag) if err != nil { diff --git a/internal/docker/client.go b/internal/docker/client.go index b4dc969..6a573b3 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -17,8 +17,7 @@ type BuildSpec struct { } type BuildResult struct { - ImageID string // the daemon's content-addressed ID - Err error + Err error } type Mount struct { @@ -32,7 +31,7 @@ type RunSpec struct { Image string Mounts []Mount Env map[string]string // joined to KEY=VALUE by Run - Labels map[string]string // e.g. patchdock.task-id + Labels map[string]string // e.g. patchdock.run-id Entrypoint []string // nil = image default; set to override Timeout time.Duration // wall-clock ceiling for the run; 0 = unlimited. } diff --git a/internal/pipeline/audit.go b/internal/pipeline/audit.go index 43c6736..a48a364 100644 --- a/internal/pipeline/audit.go +++ b/internal/pipeline/audit.go @@ -18,11 +18,11 @@ type auditRun struct { rawKept bool } -func newAuditRun(logger *auditlog.Logger, task types.Task) *auditRun { +func newAuditRun(logger *auditlog.Logger, runID string, task types.Task) *auditRun { return &auditRun{ logger: logger, rec: auditlog.Record{ - RunID: logger.LogID, + RunID: runID, Task: task, StartedAt: time.Now(), }, diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 8218ef1..84cb888 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -14,6 +14,7 @@ import ( type Pipeline struct { cfg config.Config + runID string repoDir string runner *stage.Runner logger *auditlog.Logger @@ -26,9 +27,10 @@ type Outcome struct { Patch auditlog.PatchStat } -func New(cfg config.Config, repoDir string, runner *stage.Runner, logger *auditlog.Logger, reporter Reporter) *Pipeline { +func New(cfg config.Config, runID, repoDir string, runner *stage.Runner, logger *auditlog.Logger, reporter Reporter) *Pipeline { return &Pipeline{ cfg: cfg, + runID: runID, repoDir: repoDir, reporter: reporter, runner: runner, @@ -46,7 +48,7 @@ func (p *Pipeline) Run(ctx context.Context, task types.Task) (out *Outcome, err out = &Outcome{} history := newHistory() - audit := newAuditRun(p.logger, task) + audit := newAuditRun(p.logger, p.runID, task) defer func() { audit.Finish(history, out, err) }() p.reporter.StageChange(types.StagePlanner, 0) @@ -132,8 +134,8 @@ func (p *Pipeline) Run(ctx context.Context, task types.Task) (out *Outcome, err return out, nil } - branch := p.branchName(task) - if err := wks.Publish(ctx, branch, commitMessage(task, plan)); err != nil { + branch := p.branchName() + if err := wks.Publish(ctx, branch, p.commitMessage(task, plan)); err != nil { audit.Failed(types.StageReviewer, err) return out, fmt.Errorf("publish %s: %w", branch, err) } @@ -143,22 +145,22 @@ func (p *Pipeline) Run(ctx context.Context, task types.Task) (out *Outcome, err return out, nil } -func (p *Pipeline) branchName(task types.Task) string { +func (p *Pipeline) branchName() string { prefix := p.cfg.Git.BranchPrefix if prefix == "" { - return task.ID + return p.runID } - return prefix + "/" + task.ID + return prefix + "/" + p.runID } -func commitMessage(task types.Task, plan types.Plan) string { +func (p *Pipeline) commitMessage(task types.Task, plan types.Plan) string { summary := utils.FirstLine(task.Description) if plan.Summary != "" { summary = utils.FirstLine(plan.Summary) } - return fmt.Sprintf("%s\n\nPatchdock task %s", summary, task.ID) + return fmt.Sprintf("%s\n\nPatchdock run %s", summary, p.runID) } func (p *Pipeline) agentSpec(agentFile string) stage.AgentSpec { diff --git a/internal/runtimedir/runtimedir.go b/internal/runtimedir/runtimedir.go index 56a9a6c..5f3120e 100644 --- a/internal/runtimedir/runtimedir.go +++ b/internal/runtimedir/runtimedir.go @@ -46,6 +46,10 @@ func (d Dir) Socket() string { return filepath.Join(d.root, "dock.sock") } +func (d Dir) Config() string { + return filepath.Join(d.root, "config.json") +} + func (d Dir) Lock() string { return filepath.Join(d.root, "dock.lock") } diff --git a/internal/scaffold/templates/config.template.yml b/internal/scaffold/templates/config.template.yml index 6017eae..06277af 100644 --- a/internal/scaffold/templates/config.template.yml +++ b/internal/scaffold/templates/config.template.yml @@ -22,7 +22,7 @@ container: # pass, so this multiplies what a run costs. Must be at least 1 — 0 runs nothing # and the run is reported as rejected. retries: - max: 2 + max: 3 # Host files mounted read-only into every stage container, so the agent can # authenticate diff --git a/internal/stage/container.go b/internal/stage/container.go index 4d31548..cc3de35 100644 --- a/internal/stage/container.go +++ b/internal/stage/container.go @@ -47,7 +47,7 @@ func (r *Runner) containerSpec(op runOptions, agent AgentSpec) (docker.RunSpec, Image: r.options.ImageTag, Mounts: mounts, Env: env, - Labels: map[string]string{"patchdock.task-id": op.taskID}, + Labels: map[string]string{"patchdock.run-id": r.options.RunID}, Timeout: agent.Limits.Timeout, }, nil } @@ -74,8 +74,8 @@ func (r *Runner) containerMounts(op runOptions) ([]docker.Mount, error) { func (r *Runner) containerEnv(op runOptions, agent AgentSpec) (map[string]string, error) { env := map[string]string{ - "PATCHDOCK_STAGE": string(op.stage), - "PATCHDOCK_TASK_ID": op.taskID, + "PATCHDOCK_STAGE": string(op.stage), + "PATCHDOCK_RUN_ID": r.options.RunID, } if agent.AgentFile != "" { env["PATCHDOCK_AGENT_FILE"] = agent.AgentFile diff --git a/internal/stage/executor.go b/internal/stage/executor.go index 470bb31..44164a1 100644 --- a/internal/stage/executor.go +++ b/internal/stage/executor.go @@ -28,7 +28,6 @@ func (r *Runner) RunExecutor(ctx context.Context, req ExecutorRequest) (types.Ex raw, err := r.runStage(ctx, req.Agent, runOptions{ stage: types.StageExecutor, - taskID: req.Input.Plan.TaskID, dir: req.ExchangeDir, mounts: mounts, attempt: req.Attempt.Number, @@ -38,8 +37,5 @@ func (r *Runner) RunExecutor(ctx context.Context, req ExecutorRequest) (types.Ex return types.ExecutionResult{}, err } - return decodeOutput(raw, func(er *types.ExecutionResult) { - er.TaskID = req.Input.Plan.TaskID - er.PlanID = req.Input.Plan.ID - }, types.NewExecutionResult) + return decodeOutput(raw, types.NewExecutionResult) } diff --git a/internal/stage/planner.go b/internal/stage/planner.go index 6a290f6..f6bf11f 100644 --- a/internal/stage/planner.go +++ b/internal/stage/planner.go @@ -26,7 +26,6 @@ func (r *Runner) RunPlanner(ctx context.Context, req PlannerRequest) (types.Plan raw, err := r.runStage(ctx, req.Agent, runOptions{ stage: types.StagePlanner, - taskID: req.Input.Task.ID, dir: req.ExchangeDir, mounts: mounts, }, req.Input) @@ -34,5 +33,5 @@ func (r *Runner) RunPlanner(ctx context.Context, req PlannerRequest) (types.Plan return types.Plan{}, err } - return decodeOutput(raw, func(p *types.Plan) { p.TaskID = req.Input.Task.ID }, types.NewPlan) + return decodeOutput(raw, types.NewPlan) } diff --git a/internal/stage/reviewer.go b/internal/stage/reviewer.go index 77cbec8..1bd7037 100644 --- a/internal/stage/reviewer.go +++ b/internal/stage/reviewer.go @@ -36,7 +36,6 @@ func (r *Runner) RunReviewer(ctx context.Context, req ReviewerRequest) (types.Re raw, err := r.runStage(ctx, req.Agent, runOptions{ stage: types.StageReviewer, - taskID: req.Input.Plan.TaskID, dir: req.ExchangeDir, mounts: mounts, attempt: req.Attempt.Number, @@ -46,8 +45,5 @@ func (r *Runner) RunReviewer(ctx context.Context, req ReviewerRequest) (types.Re return types.Review{}, err } - return decodeOutput(raw, func(r *types.Review) { - r.TaskID = req.Input.Plan.TaskID - r.ExecutionID = req.Input.ExecutionResults[len(req.Input.ExecutionResults)-1].ID - }, types.NewReview) + return decodeOutput(raw, types.NewReview) } diff --git a/internal/stage/runner.go b/internal/stage/runner.go index 81c291d..46ce839 100644 --- a/internal/stage/runner.go +++ b/internal/stage/runner.go @@ -23,8 +23,9 @@ type AgentSpec struct { Limits Limits } -// RunnerOptions holds what every stage in one task run shares +// RunnerOptions holds what every stage in one run shares type RunnerOptions struct { + RunID string ImageTag string PatchdockDir string LogWriter io.Writer @@ -50,12 +51,11 @@ func NewRunner(containers ContainerRunner, options RunnerOptions) *Runner { } // Used by other stages to ge the output -func decodeOutput[T any](raw []byte, stamp func(*T), build func(T) (T, error)) (T, error) { +func decodeOutput[T any](raw []byte, build func(T) (T, error)) (T, error) { var zero, decoded T if err := json.Unmarshal(raw, &decoded); err != nil { return zero, ErrOutput{Reason: reasonNotJSON, Err: err, Raw: raw} } - stamp(&decoded) out, err := build(decoded) if err != nil { diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 153e8b5..84b8f5c 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -27,7 +27,6 @@ const ( type runOptions struct { stage types.StageName - taskID string dir string mounts []docker.Mount attempt int diff --git a/internal/types/execution.go b/internal/types/execution.go index d9de171..51f8be4 100644 --- a/internal/types/execution.go +++ b/internal/types/execution.go @@ -1,15 +1,6 @@ package types -import ( - "github.com/HJyup/patchdock/internal/utils" -) - -// ExecutionResult is the executor stage's output for one Plan attempt. type ExecutionResult struct { - ID string `json:"id"` // runtime-filled - TaskID string `json:"task_id"` // runtime-filled - PlanID string `json:"plan_id"` // runtime-filled - // Status summarises the attempt. Everything else it wants to say goes in Notes Status ExecutionStatus `json:"status"` @@ -32,9 +23,6 @@ const ( ) func NewExecutionResult(x ExecutionResult) (ExecutionResult, error) { - if x.ID == "" { - x.ID = utils.NewID("exec") - } if err := x.validate(); err != nil { return ExecutionResult{}, err } @@ -43,9 +31,6 @@ func NewExecutionResult(x ExecutionResult) (ExecutionResult, error) { func (x *ExecutionResult) validate() error { var e errs - e.required("execution_result.id", x.ID) - e.required("execution_result.task_id", x.TaskID) - e.required("execution_result.plan_id", x.PlanID) switch x.Status { case ExecutionSuccess, ExecutionPartialSuccess, ExecutionFailed: case "": diff --git a/internal/types/plan.go b/internal/types/plan.go index d94d920..5c4c457 100644 --- a/internal/types/plan.go +++ b/internal/types/plan.go @@ -2,15 +2,11 @@ package types import ( "time" - - "github.com/HJyup/patchdock/internal/utils" ) // Plan is the planner stage's output: an immutable description of the work -// the executor should attempt for a single task. +// the executor should attempt for a single run. type Plan struct { - ID string `json:"id"` // runtime-filled - TaskID string `json:"task_id"` // runtime-filled CreatedAt time.Time `json:"created_at"` // runtime-filled // Summary is the planner's 1-2 sentence account of the strategy, @@ -23,9 +19,6 @@ type Plan struct { } func NewPlan(p Plan) (Plan, error) { - if p.ID == "" { - p.ID = utils.NewID("plan") - } if p.CreatedAt.IsZero() { p.CreatedAt = time.Now().UTC() } @@ -37,8 +30,6 @@ func NewPlan(p Plan) (Plan, error) { func (p *Plan) validate() error { var e errs - e.required("plan.id", p.ID) - e.required("plan.task_id", p.TaskID) if p.CreatedAt.IsZero() { e.addf("plan.created_at: empty") } diff --git a/internal/types/review.go b/internal/types/review.go index 98a9153..d86fbae 100644 --- a/internal/types/review.go +++ b/internal/types/review.go @@ -1,14 +1,7 @@ package types -import "github.com/HJyup/patchdock/internal/utils" - -// Review is the reviewer stage's output for one ExecutionResult. // Decision is the one agent-authored field the runtime branches on type Review struct { - ID string `json:"id"` // runtime-filled - TaskID string `json:"task_id"` // runtime-filled - ExecutionID string `json:"execution_id"` // runtime-filled - Decision ReviewDecision `json:"decision"` // Summary is the reviewer's 1-2 sentence verdict, surfaced in run results. @@ -32,9 +25,6 @@ const ( ) func NewReview(r Review) (Review, error) { - if r.ID == "" { - r.ID = utils.NewID("review") - } if err := r.validate(); err != nil { return Review{}, err } @@ -43,9 +33,6 @@ func NewReview(r Review) (Review, error) { func (r *Review) validate() error { var e errs - e.required("review.id", r.ID) - e.required("review.task_id", r.TaskID) - e.required("review.execution_id", r.ExecutionID) switch r.Decision { case ReviewAccept, ReviewReject: case "": diff --git a/internal/types/task.go b/internal/types/task.go index 7c34477..447c3a4 100644 --- a/internal/types/task.go +++ b/internal/types/task.go @@ -1,10 +1,6 @@ package types -import "github.com/HJyup/patchdock/internal/utils" - -// Task is the issue or prompt the planner starts from type Task struct { - ID string `json:"id"` Title string `json:"title,omitempty"` // Description is the full task: either a GitHub issue body or a user prompt. Description string `json:"description"` @@ -12,9 +8,6 @@ type Task struct { } func NewTask(t Task) (Task, error) { - if t.ID == "" { - t.ID = utils.NewID("task") - } if err := t.validate(); err != nil { return Task{}, err } @@ -23,7 +16,6 @@ func NewTask(t Task) (Task, error) { func (t *Task) validate() error { var e errs - e.required("task.id", t.ID) e.required("task.description", t.Description) return e.join() } diff --git a/internal/types/validate_test.go b/internal/types/validate_test.go index db31fcd..7a38cc8 100644 --- a/internal/types/validate_test.go +++ b/internal/types/validate_test.go @@ -4,7 +4,6 @@ import "testing" func validPlan() Plan { return Plan{ - TaskID: "task-1", Summary: "fix the bug in one file", Body: "## Approach\nEdit the file.\n\n## Acceptance criteria\n- tests pass", } @@ -28,20 +27,16 @@ func TestPlanRequiresSummaryAndBody(t *testing.T) { func TestReviewRejectRequiresFeedback(t *testing.T) { _, err := NewReview(Review{ - TaskID: "task-1", - ExecutionID: "exec-1", - Decision: ReviewReject, - Summary: "does not compile", + Decision: ReviewReject, + Summary: "does not compile", }) assertError(t, err, "review.feedback: required when decision is reject") } func TestReviewAcceptAllowsOptionalFeedback(t *testing.T) { base := Review{ - TaskID: "task-1", - ExecutionID: "exec-1", - Decision: ReviewAccept, - Summary: "looks good", + Decision: ReviewAccept, + Summary: "looks good", } if _, err := NewReview(base); err != nil { @@ -56,18 +51,14 @@ func TestReviewAcceptAllowsOptionalFeedback(t *testing.T) { func TestReviewInvalidDecision(t *testing.T) { _, err := NewReview(Review{ - TaskID: "task-1", - ExecutionID: "exec-1", - Decision: "maybe", - Summary: "hmm", + Decision: "maybe", + Summary: "hmm", }) assertError(t, err, `review.decision: invalid value "maybe"`) } func TestExecutionResultInvalidStatus(t *testing.T) { _, err := NewExecutionResult(ExecutionResult{ - TaskID: "task-1", - PlanID: "plan-1", Status: "weird", }) assertError(t, err, `execution_result.status: invalid value "weird"`) @@ -75,9 +66,7 @@ func TestExecutionResultInvalidStatus(t *testing.T) { func TestReviewJoinsMultipleErrorsInFieldOrder(t *testing.T) { _, err := NewReview(Review{}) - assertError(t, err, "review.task_id: empty\n"+ - "review.execution_id: empty\n"+ - "review.decision: empty\n"+ + assertError(t, err, "review.decision: empty\n"+ "review.summary: empty") } diff --git a/internal/utils/duration.go b/internal/utils/duration.go new file mode 100644 index 0000000..3c4ef14 --- /dev/null +++ b/internal/utils/duration.go @@ -0,0 +1,66 @@ +package utils + +import ( + "encoding/json" + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +type Duration time.Duration + +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.ScalarNode { + return fmt.Errorf("duration must be a string like \"10m\" or 0") + } + + if value.Value == "0" { + *d = 0 + return nil + } + + return d.parse(value.Value) +} + +func (d Duration) MarshalYAML() (any, error) { + return time.Duration(d).String(), nil +} + +func (d *Duration) UnmarshalJSON(data []byte) error { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return err + } + + switch v := value.(type) { + case string: + return d.parse(v) + + case float64: + if v == 0 { + *d = 0 + return nil + } + } + + return fmt.Errorf(`duration must be a string like "15m" or 0`) +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) parse(value string) error { + parsed, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", value, err) + } + + *d = Duration(parsed) + return nil +} + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} diff --git a/sdk/README.md b/sdk/README.md index b13c636..567e0de 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -61,7 +61,9 @@ interface PlanData { type PlannerRun = (ctx: StageContext, input: PlannerInput) => Promise; ``` -Patchdock adds the plan ID, task ID, and creation timestamp after the planner returns. +Patchdock adds the creation timestamp after the planner returns. Nothing in the +contracts carries an ID: the run ID (`ctx.runId`) and the attempt number address +every stage output, so agents never mint or echo identifiers. Structure inside `body` (approach, ordered steps, acceptance criteria) is a prompt convention for the executor and reviewer to read, not a schema. Keep the conventional @@ -137,9 +139,6 @@ interface ReviewData { type ReviewerRun = (ctx: StageContext, input: ReviewerInput) => Promise; ``` -Patchdock adds the review ID, task ID, and latest execution ID after the reviewer -returns. - On reject, `feedback` becomes the executor's context for the next attempt. By convention, list each issue with a severity and file:line reference so the retry knows exactly what to fix. @@ -226,7 +225,7 @@ write typed input -> validate input -> call run(ctx, input) -> validate output - -> enrich runtime-owned fields + -> stamp the plan's creation timestamp -> pass result to the next stage ``` @@ -239,7 +238,7 @@ type Stage = "planner" | "executor" | "reviewer"; interface StageContext { stage: Stage; - taskId: string; + runId: string; paths: { repo?: string; workspace?: string; @@ -260,7 +259,8 @@ interface StageLogEvent { ``` - `stage` identifies which definition is running. -- `taskId` identifies the current Patchdock task. +- `runId` identifies the current run. It is the only identity Patchdock assigns, and it + names the run's audit log directory, published branch, and stage containers. - `paths` contains the conventional mount locations available to the stage. - `tokenBudget` contains the configured budget or `null` when unlimited. - `attempt` and `maxAttempts` let retry-aware agents adapt their behaviour. @@ -269,7 +269,7 @@ interface StageLogEvent { Use `ctx.log` for progress and diagnostic information: ```typescript -ctx.log(`Running ${ctx.stage} for task ${ctx.taskId}`); +ctx.log(`Running ${ctx.stage} for run ${ctx.runId}`); ctx.log({ source: "my-agent", @@ -321,8 +321,8 @@ Contracts are checked at two boundaries: 1. The TypeScript SDK validates input before the agent runs and validates its returned output afterward. -2. The Go host enriches the result with runtime-owned fields and validates the complete - domain contract again. +2. The Go host validates the same domain contract again before the result reaches the + next stage or the audit record. Validation failures stop the stage. Invalid data is never passed to the next agent. @@ -330,12 +330,13 @@ The main rules agent authors need to respect are: - Default-export one definition matching the configured stage. - Return the output type belonging to that definition. -- Use snake-case JSON field names such as `task_id` and `execution_results`. +- Use snake-case JSON field names such as `execution_results` and `previous_reviews`. - Planner output requires a non-empty `summary` and `body`. - Executor `status` must be `success`, `partial_success`, or `failed`. - Reviewer `decision` must be `accept` or `reject`; a rejected review must carry non-empty `feedback` (accepted reviews may include it for non-blocking notes). -- Do not return runtime-owned IDs, timestamps, stage relationships, or the executor patch. +- Do not return identifiers, timestamps, or the executor patch. The run ID and the + attempt number are the runtime's to assign, and both already reach you on `ctx`. - Write executor file changes only into the writable workspace. ## Development checks diff --git a/sdk/runtime/main.ts b/sdk/runtime/main.ts index 1348559..47d9be3 100644 --- a/sdk/runtime/main.ts +++ b/sdk/runtime/main.ts @@ -58,7 +58,7 @@ async function main() { const ctx: StageContext = { stage, - taskId: process.env.PATCHDOCK_TASK_ID ?? "", + runId: process.env.PATCHDOCK_RUN_ID ?? "", // IO is never passed since it's defined by default to agents paths: { repo: REPO_PATH, workspace: WORKSPACE_PATH }, log: (entry) => writeStageLog(stage, entry), diff --git a/sdk/src/context.ts b/sdk/src/context.ts index b9f461b..14c06c5 100644 --- a/sdk/src/context.ts +++ b/sdk/src/context.ts @@ -24,7 +24,7 @@ type Nullable = T | null; interface StageContextData { stage: Stage; - taskId: string; + runId: string; paths: MountPaths; tokenBudget: Nullable; attempt: number; diff --git a/sdk/src/testing/models.ts b/sdk/src/testing/models.ts index cf394f7..3bc3062 100644 --- a/sdk/src/testing/models.ts +++ b/sdk/src/testing/models.ts @@ -2,7 +2,6 @@ import type { StageContext } from "../context.ts"; export function task() { return { - id: "task-1", title: "add farewell", description: "add a farewell function with a test", labels: ["demo"], @@ -11,8 +10,6 @@ export function task() { export function fullPlan() { return { - id: "plan-1", - task_id: "task-1", created_at: "2026-07-02T21:27:04.949582Z", summary: "small focused change", body: [ @@ -30,9 +27,6 @@ export function fullPlan() { export function fullExecutionResult() { return { - id: "exec-1", - task_id: "task-1", - plan_id: "plan-1", status: "success", notes: "Implemented farewell in src/greet.ts.", }; @@ -40,9 +34,6 @@ export function fullExecutionResult() { export function fullReview() { return { - id: "review-1", - task_id: "task-1", - execution_id: "exec-1", decision: "reject", summary: "missing test", feedback: "- **major** — no test was added; cover farewell() with one test", @@ -52,7 +43,7 @@ export function fullReview() { export function stageContext(overrides: Partial = {}): StageContext { return { stage: "planner", - taskId: "task-1", + runId: "run-1", paths: { repo: "/repo", workspace: "/workspace" }, tokenBudget: null, attempt: 1, diff --git a/sdk/src/types.test.ts b/sdk/src/types.test.ts index a7e2a35..74817fe 100644 --- a/sdk/src/types.test.ts +++ b/sdk/src/types.test.ts @@ -11,13 +11,13 @@ import { fullPlan, fullExecutionResult, fullReview, task } from "./testing/model describe("plannerInputSchema", () => { test("accepts a task without title and labels (Go omits empty fields)", () => { const result = plannerInputSchema.safeParse({ - task: { id: "task-1", description: "do the thing" }, + task: { description: "do the thing" }, }); expect(result.success).toBe(true); }); test("rejects a task without description", () => { - const result = plannerInputSchema.safeParse({ task: { id: "task-1" } }); + const result = plannerInputSchema.safeParse({ task: {} }); expect(result.success).toBe(false); }); }); @@ -96,17 +96,17 @@ describe("planDataSchema (planner output)", () => { expect(planDataSchema.safeParse({ summary: "s", body: "" }).success).toBe(false); }); - test("does not accept id/task_id — identity belongs to the host", () => { + test("strips identity fields — the run ID is the only identity", () => { const result = planDataSchema.safeParse({ summary: "s", body: "b", id: "plan-forged", - task_id: "task-forged", + run_id: "run-forged", }); expect(result.success).toBe(true); if (result.success) { expect(result.data).not.toHaveProperty("id"); - expect(result.data).not.toHaveProperty("task_id"); + expect(result.data).not.toHaveProperty("run_id"); } }); }); diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 71e522a..4523009 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -4,7 +4,6 @@ const executionStatusSchema = z.enum(["success", "partial_success", "failed"]); const reviewDecisionSchema = z.enum(["accept", "reject"]); const taskSchema = z.object({ - id: z.string().min(1), title: z.string().optional(), description: z.string().min(1), labels: z.array(z.string()).optional(), @@ -16,8 +15,6 @@ export const planDataSchema = z.object({ }); const planSchema = planDataSchema.extend({ - id: z.string().min(1), - task_id: z.string().min(1), created_at: z.string(), }); @@ -26,12 +23,6 @@ export const executionResultDataSchema = z.object({ notes: z.string().optional(), }); -const executionResultSchema = executionResultDataSchema.extend({ - id: z.string().min(1), - task_id: z.string().min(1), - plan_id: z.string().min(1), -}); - const reviewFields = z.object({ decision: reviewDecisionSchema, summary: z.string().min(1), @@ -43,26 +34,22 @@ export const reviewDataSchema = reviewFields.refine( { message: "feedback is required when decision is reject", path: ["feedback"] }, ); -const reviewSchema = reviewFields.extend({ - id: z.string().min(1), - task_id: z.string().min(1), - execution_id: z.string().min(1), -}); - export const plannerInputSchema = z.object({ task: taskSchema, }); +// History arrays are ordered oldest attempt first: index 0 is attempt 1, and +// the last entry is the attempt that just ran. export const executorInputSchema = z.object({ plan: planSchema, - reviews: z.array(reviewSchema), + reviews: z.array(reviewFields), }); export const reviewerInputSchema = z.object({ plan: planSchema, patch: z.string(), - execution_results: z.array(executionResultSchema), - previous_reviews: z.array(reviewSchema), + execution_results: z.array(executionResultDataSchema), + previous_reviews: z.array(reviewFields), }); export type PlanData = z.infer;