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
10 changes: 7 additions & 3 deletions .patchdock/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 73 additions & 0 deletions .patchdock/mock/executor.ts
Original file line number Diff line number Diff line change
@@ -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}.`,
};
},
});
29 changes: 29 additions & 0 deletions .patchdock/mock/lib.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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";
}
43 changes: 43 additions & 0 deletions .patchdock/mock/planner.ts
Original file line number Diff line number Diff line change
@@ -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"),
};
},
});
48 changes: 48 additions & 0 deletions .patchdock/mock/reviewer.ts
Original file line number Diff line number Diff line change
@@ -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)`,
};
},
});
18 changes: 18 additions & 0 deletions .patchdock/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions internal/auditlog/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions internal/auditlog/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
9 changes: 1 addition & 8 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ const (
exitNoDaemon = 3
)

var (
rootDetach bool
rootRepo string
)
var rootRepo string

var rootCmd = &cobra.Command{
Use: "dock [prompt]",
Expand All @@ -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")
}

Expand Down
9 changes: 6 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading