src/coop.js (6943 bytes)
1 // The agents of a coop session build the ideas that wait in Doing. On the ideamine server, a loop 2 // takes one waiting idea at a time and sends it to `any` in the coop session: the coop hub gives 3 // it to a free agent. The idea shows that agent. The answer of the agent closes the idea (DONE) 4 // or puts it back in Do with the answer as a note. 5 // 6 // idea.agent: { name: 'any' } waits; { name: 'coop' } is being sent; { name, msg } is with the 7 // agent `name` (agent@machine), and msg is the id of the coop message with the task. 8 9 import * as config from './config.js'; 10 import * as store from './store.js'; 11 import { clip } from './text.js'; 12 13 const ROUND_MS = 15000; 14 const SEND_MS = 60000; // a card that stays "being sent" longer than this waits again (the server stopped mid-send) 15 // ponytail: one limit for every card. A limit per size of the idea if real builds need more. 16 const ANSWER_MS = 3 * 3600000; 17 const PAGE = 200; 18 19 const now = () => new Date().toISOString(); 20 21 /** The hub, the session, and the token, or null when coop is off. The token is only in the environment. */ 22 export function settings() { 23 const url = config.get('coop_url'); 24 const token = process.env.IDEAMINE_COOP_TOKEN; 25 return url && token ? { url: url.replace(/\/+$/, ''), session: config.get('coop_session'), token } : null; 26 } 27 28 async function hub(s, method, route, body) { 29 const res = await fetch(`${s.url}/v1/admin/sessions/${encodeURIComponent(s.session)}/${route}`, { 30 method, 31 headers: { authorization: `Bearer ${s.token}`, 'content-type': 'application/json' }, 32 body: body && JSON.stringify(body), 33 signal: AbortSignal.timeout(10000), 34 }); 35 const json = await res.json().catch(() => ({})); 36 return { status: res.status, json }; 37 } 38 39 const waiting = (i, t) => i.status === 'doing' && (i.agent?.name === 'any' || (i.agent?.name === 'coop' && t - Date.parse(i.agent.at) > SEND_MS)); 40 41 /** 42 * Take the idea that waits longest for an agent. The archive lock makes the take atomic: two 43 * rounds, or two servers, never take the same idea. Null when no idea waits. 44 */ 45 export function claim() { 46 if (!store.load().ideas.some((i) => waiting(i, Date.now()))) return null; // no write when nothing waits 47 return store.mutate((db) => { 48 const t = Date.now(); 49 const idea = db.ideas.filter((i) => waiting(i, t)).sort((a, b) => a.agent.at.localeCompare(b.agent.at))[0]; 50 if (!idea) return null; 51 idea.agent = { name: 'coop', at: now() }; 52 return structuredClone(idea); 53 }); 54 } 55 56 /** Change the idea only if it still has the agent state `was` (the user can move a card at any time). */ 57 function ifStill(id, was, fn) { 58 return store.mutate((db) => { 59 const idea = store.findIdea(db, id); 60 if (!idea || idea.status !== 'doing' || idea.agent?.name !== was.name || idea.agent?.msg !== was.msg) return false; 61 fn(idea); 62 idea.updated = now(); 63 return true; 64 }); 65 } 66 67 /** The message that the agent gets. */ 68 export function task(idea) { 69 const t = idea.triage || {}; 70 const facts = [ 71 t.brief && `Plan from the triage: ${t.brief}`, 72 idea.project && `Project folder on jose's PC: ${idea.project}`, 73 t.model && `Suggested model: ${t.model}`, 74 ].filter(Boolean); 75 return [ 76 `Card IDEA-${idea.id} of jose's ideamine board: ${idea.title}`, 77 idea.text, 78 facts.join('\n'), 79 'Answer with DONE: and one line that says what you did and where (commit, branch, file), or with FAILED: and the reason.\n' + 80 'DONE moves the card to Done. Any other answer puts it back in Do with your answer as a note.', 81 ].filter(Boolean).join('\n\n'); 82 } 83 84 /** Send one waiting idea to a free agent. */ 85 async function dispatch(s, log) { 86 const idea = claim(); 87 if (!idea) return; 88 const sending = { name: 'coop' }; 89 const back = (why) => { 90 ifStill(idea.id, sending, (i) => (i.agent = { name: 'any', at: now() })); 91 if (why) log(`coop: IDEA-${idea.id} waits again: ${why}`); 92 }; 93 let res; 94 try { 95 res = await hub(s, 'POST', 'messages', { to: 'any', text: task(idea) }); 96 } catch (e) { 97 return back(`cannot reach the hub (${e.message})`); 98 } 99 // 404: no agent is free now. A worker is offline for a moment between its rounds, so try again later. 100 if (res.status === 404) return back(null); 101 if (res.status !== 200 || !res.json.id || !res.json.to) return back(`the hub answered ${res.status} ${res.json.error || ''}`.trim()); 102 const { id: msg, to } = res.json; 103 ifStill(idea.id, sending, (i) => { 104 i.agent = { name: to, msg: String(msg), at: now() }; 105 (i.notes ||= []).push({ at: now(), text: `coop gave it to ${to}` }); 106 }); 107 log(`coop: IDEA-${idea.id} went to ${to} (message ${msg})`); 108 } 109 110 /** Read the answers of the agents. DONE closes the idea; any other answer, or none in time, puts it back in Do. */ 111 async function collect(s, log) { 112 const out = store.load().ideas.filter((i) => i.status === 'doing' && i.agent?.msg); 113 if (!out.length) return; 114 const replies = new Map(); 115 for (let after = Math.min(...out.map((i) => Number(i.agent.msg))); ; ) { 116 const res = await hub(s, 'GET', `messages?after=${after}&limit=${PAGE}`); 117 if (res.status !== 200) throw new Error(`the hub answered ${res.status} to a read of the messages`); 118 const messages = res.json.messages || []; 119 for (const m of messages) if (m.reply_to) replies.set(String(m.reply_to), m); 120 if (messages.length < PAGE) break; 121 after = messages[messages.length - 1].id; 122 } 123 for (const idea of out) { 124 const reply = replies.get(idea.agent.msg); 125 const late = !reply && Date.now() - Date.parse(idea.agent.at) > ANSWER_MS; 126 if (!reply && !late) continue; 127 const done = !!reply && /^\s*DONE\b/i.test(reply.text); 128 const note = reply ? `${reply.from}: ${clip(reply.text, 500)}` : `no answer from ${idea.agent.name} in ${ANSWER_MS / 3600000} h`; 129 ifStill(idea.id, idea.agent, (i) => { 130 i.status = done ? 'done' : i.triage ? 'triaged' : 'inbox'; 131 if (done) i.closed = now(); 132 delete i.agent; 133 (i.notes ||= []).push({ at: now(), text: note }); 134 }); 135 log(`coop: IDEA-${idea.id} ${done ? 'done' : 'back in the queue'}: ${note}`); 136 } 137 } 138 139 /** One round: read the answers, then send one waiting idea. */ 140 export async function round(s, { log = () => {} } = {}) { 141 try { 142 await collect(s, log); 143 } catch (e) { 144 log(`coop: ${e.message}`); 145 } 146 await dispatch(s, log); 147 } 148 149 /** Run a round every 15 s on the ideamine server. Returns a function that stops it, or null when coop is off. */ 150 export function start({ log = () => {} } = {}) { 151 const s = settings(); 152 if (!s) return null; 153 let running = false; 154 const timer = setInterval(async () => { 155 if (running) return; 156 running = true; 157 try { 158 await round(s, { log }); 159 } catch (e) { 160 log(`coop: ${e.message}`); 161 } finally { 162 running = false; 163 } 164 }, ROUND_MS); 165 log(`coop: the agents of session ${s.session} at ${s.url} build the ideas dropped in Doing`); 166 return () => clearInterval(timer); 167 }