Skip to content

Commit c7d6f38

Browse files
committed
Autonomous tasks: durable runs, and memcode task run end to end
Milestone 2. A task can now actually execute: definition, run record, execution, persisted outcome, inbox. No triggers, no PR driver, no detection yet. A Run is immutable history. It freezes everything that decided what an execution meant — the definition's revision AND its full text, the resolved project, the expanded authority, the runtime, the limits — before any work starts. Nothing downstream re-reads the YAML, so editing a task while it runs cannot change what that run was authorized to do. The definition is stored whole rather than by reference so a run stays readable after its file is edited or deleted. Run identity is the idempotency boundary. The row is created before the work begins, and a unique index on (task, trigger occurrence) means a repeated dispatch loses at the ledger rather than by hoping two executions do not overlap. Manual runs are exempt: asking twice by hand is a deliberate act. Claiming is a conditional update, so two processes racing cannot both win. Restart semantics are explicit rather than accidental. A run whose process died is FAILED as interrupted — never silently resumed, never silently re-run. A partially finished mutating run cannot be safely continued by a process that did not see what it did, and re-running it automatically is precisely how at-least-once dispatch becomes duplicate side effects. If the work still needs doing that is a new run with a new identity, which the ledger shows. Death is detected by a dead pid on this host, or a stale heartbeat anywhere. Runs live in their own database, not the gateway's: `task run` has to work where the daemon has never started, and the ledger is an audit trail that must outlive operational state that gets pruned. The authority finding is the important part of this commit. read_only was first implemented as a stricter permission mode, on the reasoning that a detached run has no human, so every prompt is a denial. A real run disproved it: a read_only task told to create a file created it, logging "auto-allowed" in ask mode. The authorization judge downgrades a prompt to an allow when the request plainly asked for the action — a good rule interactively, where it catches an agent freelancing PAST what was wanted, and exactly backwards for a task, whose instructions always authorize the task's own work. So authority is enforced as CAPABILITY instead. The child runs with the existing read-only tool whitelist, reached through a new --read-only on `memcode run` and jobs.SpawnSpec, because a tool the session was never given cannot be argued for. Reusing that whitelist rather than hand-listing tools to deny also means it keeps covering tools added later. Note it is currently STRICTER than the tier's stated grants — a read_only task cannot run `go test` either — and honouring process.execute_readonly needs per-grant command gating in milestone 4. Too strict is the right direction to be wrong in. Outcome classification is the weak part and is knowingly provisional. It reads the agent's prose, and the same denial was reported three different ways across three runs ("was denied", "is being denied", "the run was blocked"), so the patterns err toward surfacing rather than toward calling things fine. Verify commands and a structured verdict replace this in milestone 4. Inbox state is unseen/seen/acknowledged, never a boolean: being shown a banner is not the same as having dealt with something, which matters most for exactly the needs_attention runs.
1 parent 744d9c6 commit c7d6f38

9 files changed

Lines changed: 1543 additions & 1 deletion

File tree

‎cmd/run.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ for local gateway development. Never store keys in .memcode.`,
186186
// --allow-tools/--deny-tools: a delegated job's actual toolset restriction
187187
// (see jobs.SpawnSpec.ToolPolicy). Applied here — before the --job branch —
188188
// so it binds regardless of whether the child also carries --session.
189+
// --read-only: explorer mode, the read-only tool whitelist. Applied with
190+
// the tool policy because it is the same kind of restriction — what the
191+
// child CAN do, decided before it starts, rather than what it may be
192+
// talked into at the gate.
193+
if ro, _ := cmd.Flags().GetBool("read-only"); ro {
194+
sess.SetReadOnly(true)
195+
}
189196
allowTools, _ := cmd.Flags().GetString("allow-tools")
190197
denyTools, _ := cmd.Flags().GetString("deny-tools")
191198
if allowTools != "" || denyTools != "" {
@@ -362,6 +369,8 @@ func init() {
362369
_ = runCmd.Flags().MarkHidden("allow-tools")
363370
runCmd.Flags().String("deny-tools", "", "internal: comma-separated toolset/tool deny-list for a delegated job (deny wins)")
364371
_ = runCmd.Flags().MarkHidden("deny-tools")
372+
runCmd.Flags().Bool("read-only", false, "internal: explorer mode — read-only tools only, no edits and no bash")
373+
_ = runCmd.Flags().MarkHidden("read-only")
365374
runCmd.Flags().String("model", "", "model for this run (a catalog label like sonnet or opus); overrides the remembered pin without changing it")
366375
runCmd.Flags().String("browser-session", "", "internal: \"existing_chrome\" attaches this run to the user's own already-running Chrome via the gateway browser broker (fails closed, never falls back to ephemeral)")
367376
_ = runCmd.Flags().MarkHidden("browser-session")

‎cmd/task.go‎

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
"os/exec"
@@ -12,8 +13,38 @@ import (
1213

1314
"github.com/memcode-ai/memcode/internal/config"
1415
"github.com/memcode-ai/memcode/internal/task"
16+
"github.com/memcode-ai/memcode/internal/taskrun"
1517
)
1618

19+
// openRuns opens the run ledger and settles anything a dead process left
20+
// behind. Reconciling on every open means an interrupted run is discovered the
21+
// next time anyone looks, rather than sitting as "running" forever.
22+
func openRuns(ctx context.Context) (*taskrun.Store, error) {
23+
s, err := taskrun.OpenDefault(ctx)
24+
if err != nil {
25+
return nil, err
26+
}
27+
if _, err := s.Reconcile(ctx, time.Now()); err != nil {
28+
s.Close()
29+
return nil, err
30+
}
31+
return s, nil
32+
}
33+
34+
// outcomeMark is the one-glyph verdict used in listings.
35+
func outcomeMark(o taskrun.Outcome) string {
36+
switch o {
37+
case taskrun.OutcomeSuccess, taskrun.OutcomeNoChange:
38+
return "OK"
39+
case taskrun.OutcomeNeedsAttention:
40+
return "!!"
41+
case taskrun.OutcomeInterrupted:
42+
return ".."
43+
default:
44+
return "XX"
45+
}
46+
}
47+
1748
var taskCmd = &cobra.Command{
1849
Use: "task",
1950
Aliases: []string{"tasks"},
@@ -257,7 +288,206 @@ is silently not running rather than loudly broken.`,
257288
},
258289
}
259290

291+
var taskRunCmd = &cobra.Command{
292+
Use: "run <name>",
293+
Short: "Run a task now and record the result",
294+
Long: `Runs a task immediately, in the foreground, and writes a durable run record.
295+
296+
This is the ordinary way to use a task, not a way to test one. A task is a runnable object;
297+
attaching a trigger is what makes it also happen on its own.
298+
299+
The run freezes its inputs when it starts — the definition and its revision, the resolved
300+
project, the expanded authority, the runtime, the limits — so editing the task file while it
301+
runs cannot change what the run meant.`,
302+
Args: cobra.ExactArgs(1),
303+
RunE: func(cmd *cobra.Command, args []string) error {
304+
ctx := cmd.Context()
305+
root := taskRoot()
306+
t, err := task.Get(root, args[0], time.Now())
307+
if err != nil {
308+
return err
309+
}
310+
store, err := openRuns(ctx)
311+
if err != nil {
312+
return err
313+
}
314+
defer store.Close()
315+
316+
// Refuse to pile a second run onto a task that is already going. The full
317+
// concurrency policy (queue vs skip, per project) lands with triggers;
318+
// this is the floor that stops the obvious foot-gun.
319+
if active, err := store.Active(ctx, t.Name); err == nil && len(active) > 0 {
320+
return fmt.Errorf("%s is already running (%s) — wait for it or stop that process",
321+
t.Name, active[0].ID)
322+
}
323+
324+
fmt.Printf("%s · %s · %s\n", t.Name, t.Autonomy.Level, t.Agent.Provider)
325+
run, err := taskrun.NewRunner(store).Run(ctx, t, root, taskrun.TriggerManual, "")
326+
if err != nil {
327+
return err
328+
}
329+
fmt.Printf("\n%s %s %s\n", outcomeMark(run.Outcome), run.Outcome, run.Summary)
330+
fmt.Printf(" run %s · %s\n", run.ID, run.Revision)
331+
if run.LogPath != "" {
332+
fmt.Printf(" log %s\n", run.LogPath)
333+
}
334+
if run.Detail != "" && run.Outcome != taskrun.OutcomeSuccess {
335+
fmt.Printf("\n%s\n", run.Detail)
336+
}
337+
if !run.OK() {
338+
return fmt.Errorf("task %s: %s", t.Name, run.Outcome)
339+
}
340+
return nil
341+
},
342+
}
343+
344+
var taskHistoryCmd = &cobra.Command{
345+
Use: "history [name]",
346+
Aliases: []string{"runs"},
347+
Short: "Show past runs, newest first",
348+
Args: cobra.MaximumNArgs(1),
349+
RunE: func(cmd *cobra.Command, args []string) error {
350+
ctx := cmd.Context()
351+
store, err := openRuns(ctx)
352+
if err != nil {
353+
return err
354+
}
355+
defer store.Close()
356+
var name string
357+
if len(args) == 1 {
358+
name = args[0]
359+
}
360+
runs, err := store.Recent(ctx, name, 30)
361+
if err != nil {
362+
return err
363+
}
364+
if len(runs) == 0 {
365+
fmt.Println("No runs yet.")
366+
return nil
367+
}
368+
for _, r := range runs {
369+
took := ""
370+
if !r.FinishedAt.IsZero() {
371+
took = r.FinishedAt.Sub(r.StartedAt).Round(time.Second).String()
372+
}
373+
fmt.Printf(" %s %-16s %-22s %-8s %-7s %s\n",
374+
outcomeMark(r.Outcome), r.Outcome, r.Task,
375+
r.StartedAt.Local().Format("Jan 02 15:04"), took, r.Summary)
376+
}
377+
return nil
378+
},
379+
}
380+
381+
var taskInboxCmd = &cobra.Command{
382+
Use: "inbox",
383+
Short: "Show runs you have not looked at yet",
384+
Long: `The inbox is the durable ledger of autonomous activity. Every run lands here whether
385+
or not anyone was watching; other delivery (a PR, a chat message, a desktop notification) is
386+
notification layered on top of it, never a replacement.
387+
388+
Listing marks runs as seen. Seen is not the same as dealt with: use ` + "`task ack`" + ` for that.`,
389+
RunE: func(cmd *cobra.Command, args []string) error {
390+
ctx := cmd.Context()
391+
store, err := openRuns(ctx)
392+
if err != nil {
393+
return err
394+
}
395+
defer store.Close()
396+
runs, err := store.Unseen(ctx, 50)
397+
if err != nil {
398+
return err
399+
}
400+
if len(runs) == 0 {
401+
fmt.Println("Nothing new.")
402+
return nil
403+
}
404+
fmt.Printf("Autonomous activity — %d run(s) since you last looked\n", len(runs))
405+
ids := make([]string, 0, len(runs))
406+
for _, r := range runs {
407+
fmt.Printf(" %s %-22s %-16s %s\n", outcomeMark(r.Outcome), r.Task, r.Outcome, r.Summary)
408+
ids = append(ids, r.ID)
409+
}
410+
needs := 0
411+
for _, r := range runs {
412+
if r.Outcome == taskrun.OutcomeNeedsAttention {
413+
needs++
414+
}
415+
}
416+
if needs > 0 {
417+
fmt.Printf("\n%d need your attention — `memcode task show-run <id>` for the detail.\n", needs)
418+
}
419+
return store.MarkSeen(ctx, ids)
420+
},
421+
}
422+
423+
var taskAckCmd = &cobra.Command{
424+
Use: "ack <run-id>",
425+
Short: "Mark a run as dealt with",
426+
Args: cobra.ExactArgs(1),
427+
RunE: func(cmd *cobra.Command, args []string) error {
428+
ctx := cmd.Context()
429+
store, err := openRuns(ctx)
430+
if err != nil {
431+
return err
432+
}
433+
defer store.Close()
434+
if err := store.Acknowledge(ctx, args[0]); err != nil {
435+
return err
436+
}
437+
fmt.Printf("acknowledged %s\n", args[0])
438+
return nil
439+
},
440+
}
441+
442+
var taskShowRunCmd = &cobra.Command{
443+
Use: "show-run <run-id>",
444+
Short: "Show one run in full, including the definition it froze",
445+
Args: cobra.ExactArgs(1),
446+
RunE: func(cmd *cobra.Command, args []string) error {
447+
ctx := cmd.Context()
448+
store, err := openRuns(ctx)
449+
if err != nil {
450+
return err
451+
}
452+
defer store.Close()
453+
r, err := store.Get(ctx, args[0])
454+
if err != nil {
455+
return err
456+
}
457+
fmt.Printf("%s\n", r.ID)
458+
fmt.Printf(" task %s\n", r.Task)
459+
fmt.Printf(" revision %s\n", r.Revision)
460+
fmt.Printf(" trigger %s", r.TriggerKind)
461+
if r.TriggerID != "" {
462+
fmt.Printf(" (%s)", r.TriggerID)
463+
}
464+
fmt.Printf("\n project %s\n", r.Project)
465+
fmt.Printf(" runtime %s / %s\n", r.Provider, r.Model)
466+
fmt.Printf(" authority %s\n", strings.Join(r.Grants, ", "))
467+
fmt.Printf(" state %s / %s (%s)\n", r.State, r.Outcome, r.Seen)
468+
fmt.Printf(" started %s\n", r.StartedAt.Local().Format(time.RFC3339))
469+
if !r.FinishedAt.IsZero() {
470+
fmt.Printf(" finished %s (%s)\n", r.FinishedAt.Local().Format(time.RFC3339),
471+
r.FinishedAt.Sub(r.StartedAt).Round(time.Second))
472+
}
473+
if r.LogPath != "" {
474+
fmt.Printf(" log %s\n", r.LogPath)
475+
}
476+
if r.Summary != "" {
477+
fmt.Printf("\n %s\n", r.Summary)
478+
}
479+
if r.Detail != "" {
480+
fmt.Printf("\n%s\n", r.Detail)
481+
}
482+
// The frozen definition is the whole point of the record: it is what this
483+
// run actually ran, regardless of what the file says now.
484+
fmt.Printf("\n--- definition as frozen at run time ---\n%s", r.Definition)
485+
return nil
486+
},
487+
}
488+
260489
func init() {
261-
taskCmd.AddCommand(taskListCmd, taskShowCmd, taskCheckCmd)
490+
taskCmd.AddCommand(taskListCmd, taskShowCmd, taskCheckCmd,
491+
taskRunCmd, taskHistoryCmd, taskInboxCmd, taskAckCmd, taskShowRunCmd)
262492
rootCmd.AddCommand(taskCmd)
263493
}

‎internal/agent/runtime/ui.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ func (s *Session) SetBrowserHeadless(on bool) { s.browserHeadless = on }
193193
// — a tool that can never run must not be offered.
194194
func (s *Session) SetNoApprover(on bool) { s.noApprover = on }
195195

196+
// SetReadOnly puts the session in explorer mode: the read-only tool whitelist,
197+
// no edit_file and no bash. This is a CAPABILITY restriction, not a permission
198+
// one, and that distinction is the point — the permission gate can be argued
199+
// past (the authorization judge downgrades a prompt to an allow when the
200+
// request plainly asked for the action), but a tool the session was never given
201+
// cannot be reached at all.
202+
func (s *Session) SetReadOnly(on bool) { s.readOnly = on }
203+
196204
// SetEffortOverride forces the per-turn thinking effort from the /effort command: "off",
197205
// "medium", or "high" pin it every turn; "auto" (or anything else) clears the override and
198206
// returns to the per-turn heuristic (effortForTurn). EffortOverride reports the current setting.

‎internal/jobs/jobs.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ type SpawnSpec struct {
5454
ResourceGrant ResourceGrant
5555
Budgets ExecutionBudgets
5656
ReportBack bool
57+
// ReadOnly puts the child in explorer mode: the read-only tool whitelist, no
58+
// edits and no bash. A capability the child does not have cannot be talked
59+
// past, which matters because the permission gate alone is not enough here —
60+
// the authorization judge can downgrade a prompt to an allow when the task
61+
// text plainly asks for the action, and a task's instructions ALWAYS ask for
62+
// the task's own work.
63+
ReadOnly bool
5764
}
5865

5966
type Job struct {
@@ -182,6 +189,9 @@ func SpawnWithSpec(spec SpawnSpec) (Job, error) {
182189
if len(spec.ToolPolicy.Disabled) > 0 {
183190
argv = append(argv, "--deny-tools", strings.Join(spec.ToolPolicy.Disabled, ","))
184191
}
192+
if spec.ReadOnly {
193+
argv = append(argv, "--read-only")
194+
}
185195
if isTestBinary(self) {
186196
// Under `go test`, os.Executable() is the package's TEST binary, not memcode.
187197
// Re-execing it as `agent …` runs the caller's whole test suite again: the

‎internal/taskrun/process_unix.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//go:build !windows
2+
3+
package taskrun
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
// processAlive reports whether a pid is still running. Signal 0 performs the
11+
// permission and existence checks without delivering anything.
12+
//
13+
// This only ever runs against a pid recorded on THIS host (Reconcile checks the
14+
// hostname first), so a recycled pid is the only false positive, and its cost is
15+
// waiting out the heartbeat timeout rather than a wrong verdict.
16+
func processAlive(pid int) bool {
17+
p, err := os.FindProcess(pid)
18+
if err != nil {
19+
return false
20+
}
21+
return p.Signal(syscall.Signal(0)) == nil
22+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build windows
2+
3+
package taskrun
4+
5+
import "os"
6+
7+
// processAlive reports whether a pid is still running. On Windows FindProcess
8+
// fails outright for a dead pid, so existence is the whole check.
9+
func processAlive(pid int) bool {
10+
_, err := os.FindProcess(pid)
11+
return err == nil
12+
}

0 commit comments

Comments
 (0)