diff --git a/main.go b/main.go
index 0a4b69c5..c979ecbb 100644
--- a/main.go
+++ b/main.go
@@ -102,6 +102,14 @@ func main() {
}
return a.Slug, a.Name, nil
}
+ work.ConsumeCredits = func(userID string, amount int) error {
+ w := wallet.GetWallet(userID)
+ if w.Balance < amount {
+ return fmt.Errorf("insufficient credits (%d available, %d needed)", w.Balance, amount)
+ }
+ wallet.ConsumeQuota(userID, wallet.OpChatQuery)
+ return nil
+ }
work.Notify = func(toUserID, subject, body string) {
acc, err := auth.GetAccount(toUserID)
if err != nil {
diff --git a/work/handlers.go b/work/handlers.go
index 9476bb5c..a9b2b470 100644
--- a/work/handlers.go
+++ b/work/handlers.go
@@ -135,7 +135,11 @@ func handleList(w http.ResponseWriter, r *http.Request) {
meta := fmt.Sprintf(`%s · %s · %s`, kindLabel, post.Author, post.Author, post.CreatedAt.Format("2 Jan 2006"))
if post.Kind == KindTask && post.Cost > 0 {
- meta += fmt.Sprintf(` · %d credits`, post.Cost)
+ if post.Spent > 0 {
+ meta += fmt.Sprintf(` · %d/%d credits`, post.Spent, post.Cost)
+ } else {
+ meta += fmt.Sprintf(` · %d credits`, post.Cost)
+ }
}
if len(post.Feedback) > 0 {
meta += fmt.Sprintf(` · %d feedback`, len(post.Feedback))
@@ -195,7 +199,9 @@ func handleDetail(w http.ResponseWriter, r *http.Request) {
sb.WriteString(fmt.Sprintf(`
%s
`, detailMeta))
if post.Kind == KindTask {
- sb.WriteString(fmt.Sprintf(`Cost: %d credits (%s)
`, post.Cost, wallet.FormatCredits(post.Cost)))
+ if post.Cost > 0 {
+ sb.WriteString(fmt.Sprintf(`Budget: %d credits · Spent: %d credits
`, post.Cost, post.Spent))
+ }
if post.Status != "" {
sb.WriteString(fmt.Sprintf(`Status: %s
`, post.Status))
}
@@ -229,6 +235,28 @@ func handleDetail(w http.ResponseWriter, r *http.Request) {
sb.WriteString(``)
}
+ // Agent log
+ if len(post.Log) > 0 {
+ sb.WriteString(``)
+ sb.WriteString(`
Agent Log
`)
+ for _, entry := range post.Log {
+ color := "#555"
+ switch entry.Step {
+ case "error", "budget":
+ color = "#c00"
+ case "complete":
+ color = "#28a745"
+ }
+ credits := ""
+ if entry.Credits > 0 {
+ credits = fmt.Sprintf(` · %d credits`, entry.Credits)
+ }
+ sb.WriteString(fmt.Sprintf(`
%s %s%s %s
`,
+ color, entry.Step, entry.Message, credits, entry.CreatedAt.Format("15:04:05")))
+ }
+ sb.WriteString(`
`)
+ }
+
// Actions
if sess != nil {
actions := false
@@ -383,7 +411,7 @@ func renderPostForm(kind, errMsg string) string {
costDisplay = "block"
}
sb.WriteString(fmt.Sprintf(``, costDisplay))
- sb.WriteString(``)
+ sb.WriteString(``)
sb.WriteString(`
`)
sb.WriteString(fmt.Sprintf(``, costDisplay))
@@ -481,41 +509,24 @@ func handlePost(w http.ResponseWriter, r *http.Request) {
kind = KindShow
}
- // Hold cost in escrow for tasks
+ // Validate budget
if kind == KindTask && cost > 0 && sess.Account != "micro" {
- if err := wallet.HoldEscrow(sess.Account, cost, "pending"); err != nil {
- respondError(w, r, "/work?kind=task", "Insufficient credits for task cost")
+ wal := wallet.GetWallet(sess.Account)
+ if wal.Balance < cost {
+ respondError(w, r, "/work?kind=task", fmt.Sprintf("Insufficient credits (%d available, %d budget)", wal.Balance, cost))
return
}
}
post, err := CreatePost(sess.Account, acc.Name, kind, title, description, link, "", cost)
if err != nil {
- if kind == KindTask && cost > 0 && sess.Account != "micro" {
- wallet.RefundEscrow(sess.Account, cost, "failed")
- }
respondError(w, r, "/work?kind="+kind, err.Error())
return
}
// Assign to agent if requested
if assign && kind == KindTask {
- canProceed, _, agentCost, qerr := wallet.CheckQuota(sess.Account, wallet.OpChatQuery)
- if canProceed {
- wallet.ConsumeQuota(sess.Account, wallet.OpChatQuery)
- AssignToAgent(post.ID, sess.Account)
- } else {
- msg := fmt.Sprintf("Task posted but agent assignment failed: insufficient credits (%d required)", agentCost)
- if qerr != nil {
- msg = "Task posted but agent assignment failed: " + qerr.Error()
- }
- if app.SendsJSON(r) || app.WantsJSON(r) {
- app.RespondJSON(w, map[string]interface{}{"post": post, "warning": msg})
- return
- }
- http.Redirect(w, r, "/work/"+post.ID+"?error="+strings.ReplaceAll(msg, " ", "+"), http.StatusSeeOther)
- return
- }
+ AssignToAgent(post.ID, sess.Account)
}
if app.SendsJSON(r) || app.WantsJSON(r) {
@@ -709,15 +720,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request) {
return
}
- // Pay out: if agent did the work, refund cost to poster (they only paid compute).
- // If a human did it, release escrow to the worker.
- if post.AuthorID != "micro" {
- if post.WorkerID == "agent" {
- wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID)
- } else {
- wallet.ReleaseEscrow(post.WorkerID, post.Cost, post.ID)
- }
- }
+ // Credits already consumed during agent work — nothing to release
// Notify the worker (if human)
if post.WorkerID != "agent" && post.WorkerID != "" {
@@ -766,10 +769,7 @@ func handleCancel(w http.ResponseWriter, r *http.Request) {
return
}
- // Refund escrow to poster
- if post.AuthorID != "micro" {
- wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID)
- }
+ // Credits already consumed — no refund
if app.SendsJSON(r) || app.WantsJSON(r) {
app.RespondJSON(w, map[string]string{"status": "cancelled"})
@@ -788,18 +788,6 @@ func handleAssign(w http.ResponseWriter, r *http.Request) {
id := extractPostID(r.URL.Path, "/assign")
- // Consume credits for the agent build (same cost as apps_build = chat_query = 3 credits)
- canProceed, _, cost, qerr := wallet.CheckQuota(sess.Account, wallet.OpChatQuery)
- if !canProceed {
- msg := fmt.Sprintf("Insufficient credits (%d required)", cost)
- if qerr != nil {
- msg = qerr.Error()
- }
- respondPostError(w, r, id, msg)
- return
- }
- wallet.ConsumeQuota(sess.Account, wallet.OpChatQuery)
-
if err := AssignToAgent(id, sess.Account); err != nil {
respondPostError(w, r, id, err.Error())
return
@@ -849,13 +837,6 @@ func handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
- // Refund escrowed cost if task is open/claimed
- if post.Kind == KindTask && post.Cost > 0 && post.AuthorID != "micro" {
- if post.Status == StatusOpen || post.Status == StatusClaimed {
- wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID)
- }
- }
-
if err := DeletePost(id); err != nil {
respondPostError(w, r, id, err.Error())
return
diff --git a/work/work.go b/work/work.go
index 3b637c77..8e519967 100644
--- a/work/work.go
+++ b/work/work.go
@@ -34,7 +34,8 @@ type Post struct {
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"` // URL, app slug, or any external link
- Cost int `json:"cost"` // Credits cost for the task
+ Cost int `json:"cost"` // Max spend budget (credits)
+ Spent int `json:"spent"` // Credits consumed so far
AuthorID string `json:"author_id"`
Author string `json:"author"` // Display name
WorkerID string `json:"worker_id"` // Who claimed a task
@@ -43,6 +44,7 @@ type Post struct {
Delivery string `json:"delivery"` // Deliverable for tasks
Tags string `json:"tags"` // Comma-separated
Tips int `json:"tips"` // Total tips received (show)
+ Log []LogEntry `json:"log"` // Agent work log
Feedback []Comment `json:"feedback"` // Comments/feedback
CreatedAt time.Time `json:"created_at"`
ClaimedAt time.Time `json:"claimed_at,omitempty"`
@@ -50,6 +52,14 @@ type Post struct {
CompletedAt time.Time `json:"completed_at,omitempty"`
}
+// LogEntry records a step in the agent's work
+type LogEntry struct {
+ Step string `json:"step"` // "build", "verify", "fix", "complete", "error", "budget"
+ Message string `json:"message"` // What happened
+ Credits int `json:"credits"` // Credits consumed in this step
+ CreatedAt time.Time `json:"created_at"`
+}
+
// Comment is a piece of feedback on a work post
type Comment struct {
ID string `json:"id"`
@@ -63,6 +73,18 @@ type Comment struct {
// Takes (prompt, authorID, authorName) and returns (appSlug, appName, error).
var BuildApp func(prompt, authorID, authorName string) (string, string, error)
+// VerifyApp is wired by main.go to check if an app works.
+// Takes (appSlug) and returns (issues string, ok bool).
+var VerifyApp func(appSlug string) (string, bool)
+
+// FixApp is wired by main.go to fix issues with an app.
+// Takes (appSlug, issues) and returns (error).
+var FixApp func(appSlug, issues string) error
+
+// ConsumeCredits is wired by main.go to charge credits.
+// Takes (userID, amount) and returns (error).
+var ConsumeCredits func(userID string, amount int) error
+
// Notify is wired by main.go to send notifications (e.g. internal mail).
// Takes (toUserID, subject, body).
var Notify func(toUserID, subject, body string)
@@ -83,6 +105,13 @@ func Load() {
seedPosts()
}
data.RegisterDeleter("work", DeletePost)
+
+ // Resume any in-progress agent tasks after startup
+ // (delayed to allow callbacks to be wired first)
+ go func() {
+ time.Sleep(2 * time.Second)
+ ResumeAgentWork()
+ }()
}
func save() {
@@ -301,6 +330,39 @@ func ReleaseTask(postID, authorID string) error {
return nil
}
+// addLog appends a log entry to a post and persists it.
+func addLog(post *Post, step, message string, credits int) {
+ mutex.Lock()
+ post.Log = append(post.Log, LogEntry{
+ Step: step,
+ Message: message,
+ Credits: credits,
+ CreatedAt: time.Now(),
+ })
+ post.Spent += credits
+ save()
+ mutex.Unlock()
+}
+
+// spendCredits charges credits against a task's budget.
+// Returns false if the budget would be exceeded.
+func spendCredits(post *Post, authorID string, amount int) bool {
+ if post.Cost > 0 && post.Spent+amount > post.Cost {
+ return false
+ }
+ if ConsumeCredits != nil {
+ if err := ConsumeCredits(authorID, amount); err != nil {
+ return false
+ }
+ }
+ return true
+}
+
+const (
+ maxAgentIterations = 5
+ creditPerStep = 3 // credits per AI call
+)
+
// AssignToAgent assigns an open task to the AI agent.
// It claims the task as "agent", runs the app builder in a goroutine,
// and posts the delivery when complete. The poster reviews the result.
@@ -331,52 +393,132 @@ func AssignToAgent(postID, authorID string) error {
save()
mutex.Unlock()
- // Run the builder in background
- go func() {
- if BuildApp == nil {
- // No builder wired — release back to open
- mutex.Lock()
- post.WorkerID = ""
- post.Worker = ""
- post.Status = StatusOpen
- post.ClaimedAt = time.Time{}
- save()
- mutex.Unlock()
- return
+ // Run the agent in background
+ go runAgent(post)
+
+ return nil
+}
+
+// runAgent executes the iterative build loop for a task.
+// Build → verify → fix → verify → ... until done or budget exceeded.
+// All progress is logged and persisted so it survives restarts.
+func runAgent(post *Post) {
+ if BuildApp == nil {
+ failAgent(post, "No builder configured")
+ return
+ }
+
+ authorID := post.AuthorID
+ description := post.Description
+
+ // Step 1: Build
+ if !spendCredits(post, authorID, creditPerStep) {
+ addLog(post, "budget", "Budget exceeded before build", 0)
+ failAgent(post, "Budget exceeded")
+ return
+ }
+
+ addLog(post, "build", "Building app from description...", creditPerStep)
+
+ slug, name, err := BuildApp(description, "agent", "agent")
+ if err != nil {
+ addLog(post, "error", fmt.Sprintf("Build failed: %v", err), 0)
+ failAgent(post, "Build failed: "+err.Error())
+ return
+ }
+
+ addLog(post, "build", fmt.Sprintf("Built %s (/apps/%s/run)", name, slug), 0)
+
+ // Steps 2-N: Verify and fix loop
+ for i := 0; i < maxAgentIterations; i++ {
+ // Verify
+ if VerifyApp == nil {
+ // No verifier — accept what we have
+ break
}
- slug, name, err := BuildApp(post.Description, "agent", "agent")
- if err != nil {
- // Failed — release back to open
- mutex.Lock()
- post.WorkerID = ""
- post.Worker = ""
- post.Status = StatusOpen
- post.ClaimedAt = time.Time{}
- save()
- mutex.Unlock()
- return
+ if !spendCredits(post, authorID, creditPerStep) {
+ addLog(post, "budget", "Budget exceeded during verification", 0)
+ break
}
- // Deliver the result
- delivery := fmt.Sprintf("%s — /apps/%s/run", name, slug)
- mutex.Lock()
- post.Delivery = delivery
- post.Status = StatusDelivered
- post.DeliveredAt = time.Now()
- save()
- authorID := post.AuthorID
- title := post.Title
- postID := post.ID
- mutex.Unlock()
+ issues, ok := VerifyApp(slug)
+ addLog(post, "verify", fmt.Sprintf("Verify attempt %d: %s", i+1, issues), creditPerStep)
- if Notify != nil {
- Notify(authorID, "Agent completed: "+title,
- fmt.Sprintf("The agent built %s for your task. Review it at /work/%s", name, postID))
+ if ok {
+ addLog(post, "verify", "App verified successfully", 0)
+ break
}
- }()
- return nil
+ // Fix
+ if FixApp == nil {
+ break
+ }
+
+ if !spendCredits(post, authorID, creditPerStep) {
+ addLog(post, "budget", "Budget exceeded during fix", 0)
+ break
+ }
+
+ if err := FixApp(slug, issues); err != nil {
+ addLog(post, "error", fmt.Sprintf("Fix failed: %v", err), creditPerStep)
+ break
+ }
+
+ addLog(post, "fix", fmt.Sprintf("Applied fix for: %s", issues), creditPerStep)
+ }
+
+ // Deliver the result
+ delivery := fmt.Sprintf("%s — /apps/%s/run", name, slug)
+ mutex.Lock()
+ post.Delivery = delivery
+ post.Status = StatusDelivered
+ post.DeliveredAt = time.Now()
+ save()
+ title := post.Title
+ postID := post.ID
+ mutex.Unlock()
+
+ addLog(post, "complete", fmt.Sprintf("Delivered: %s (spent %d credits)", delivery, post.Spent), 0)
+
+ if Notify != nil {
+ Notify(authorID, "Agent completed: "+title,
+ fmt.Sprintf("The agent built %s for your task. Spent %d of %d credits. Review it at /work/%s", name, post.Spent, post.Cost, postID))
+ }
+}
+
+// failAgent marks a task as failed and releases it back to open.
+func failAgent(post *Post, reason string) {
+ mutex.Lock()
+ post.WorkerID = ""
+ post.Worker = ""
+ post.Status = StatusOpen
+ post.ClaimedAt = time.Time{}
+ save()
+ authorID := post.AuthorID
+ title := post.Title
+ mutex.Unlock()
+
+ if Notify != nil {
+ Notify(authorID, "Agent failed: "+title, reason)
+ }
+}
+
+// ResumeAgentWork restarts any in-progress agent tasks (e.g. after server restart).
+func ResumeAgentWork() {
+ mutex.RLock()
+ var inProgress []*Post
+ for _, p := range posts {
+ if p.WorkerID == "agent" && p.Status == StatusClaimed {
+ inProgress = append(inProgress, p)
+ }
+ }
+ mutex.RUnlock()
+
+ for _, p := range inProgress {
+ fmt.Printf("[work] Resuming agent task: %s\n", p.Title)
+ go runAgent(p)
+ }
}
// DeletePost removes a work post by ID