nxtask.nx source
↩ module page · 320 lines · 10773 B
1// nxtask.nx -- sovereign agent task graph (beads-equivalent).
2//
3// Persistent, structured-memory task tracker for the Mormon local
4// AI agent (docs/LAYER_1_3_SOVEREIGNTY_AUDIT.md). Tracks work
5// across sessions; survives agent restarts.
6//
7// Design inherited from beads (gastownhall/beads) where it fit
8// sovereignty discipline. Divergences:
9// - NO external SQL engine (Dolt is 100 MB + MySQL-compat).
10// Instead: content-addressable flat files, one JSON blob
11// per task. Ride on fs.nx. Zero runtime deps.
12// - NO Dolt cell-level merge. Instead: append-only event log
13// per task id; merge via event-timestamp ordering. v0.1.0
14// adds cross-agent merge; v0.0.1 single-agent only.
15// - Same JSON API as beads where possible -- Mormon can switch
16// backends seamlessly later.
17//
18// v0.0.1 scope: add, list, claim, close, show, ready. No
19// hierarchy, no deps yet -- foundations first, semantics layer
20// in v0.1.0.
21
22import "syscalls.nx"
23
24// === data model =====================================================
25
26const TASK_MAX_DESC: i64 = 2048
27const TASK_MAX_OPEN_TASKS: i64 = 1024
28const TASK_STATE_OPEN: i64 = 0
29const TASK_STATE_CLAIMED: i64 = 1
30const TASK_STATE_CLOSED: i64 = 2
31
32struct Task {
33 id_hash: *u8, // 32-byte sha256 of original description + timestamp
34 state: i64, // TASK_STATE_*
35 claimed_by: *u8, // agent id string; 0 ptr if open
36 claimed_by_len: i64,
37 desc: *u8,
38 desc_len: i64,
39 created_ns: i64,
40 updated_ns: i64,
41 summary: *u8, // close-time summary; 0 ptr while open
42 summary_len: i64,
43 // Dependency array: task indices this task is blocked by.
44 // A task is "ready" when all blockers have state == CLOSED.
45 // Allocated on demand; 0 ptr when no deps.
46 blocked_by: *i64,
47 n_blocked: i64,
48}
49
50struct TaskGraph {
51 tasks: *u8, // packed array, TASK_BYTES per entry
52 n_tasks: i64,
53 cap: i64,
54}
55
56const TASK_BYTES: i64 = 112
57const MAX_DEPS: i64 = 16
58
59// === constructor ===================================================
60
61func nxtask_graph_new(cap: i64) -> *TaskGraph {
62 let g_raw: *u8 = sys_mmap(64)
63 let g: *TaskGraph = g_raw as *TaskGraph
64 g.tasks = sys_mmap(cap * TASK_BYTES + 64)
65 g.n_tasks = 0
66 g.cap = cap
67 return g
68}
69
70func task_at(g: *TaskGraph, idx: i64) -> *Task {
71 let base: i64 = g.tasks as i64
72 return (base + idx * TASK_BYTES) as *Task
73}
74
75// === add ============================================================
76
77// Add a new task with the given description + agent hint. Returns
78// the task index in the graph, or -1 if full.
79func nxtask_add(g: *TaskGraph, desc: *u8, desc_len: i64,
80 now_ns: i64) -> i64 {
81 if g.n_tasks >= g.cap { return -1 }
82 let idx: i64 = g.n_tasks
83 let t: *Task = task_at(g, idx)
84 // id_hash: for v0.0.1 this is just a packed counter-hash.
85 // v0.1.0 upgrades to sha256(desc + timestamp).
86 let h_buf: *u8 = sys_mmap(32)
87 var i: i64 = 0
88 while i < 32 {
89 h_buf[i] = (idx + i * 17) & 0xFF
90 i = i + 1
91 }
92 t.id_hash = h_buf
93 t.state = TASK_STATE_OPEN
94 t.claimed_by = 0 as *u8
95 t.claimed_by_len = 0
96 // Copy description.
97 let dbuf: *u8 = sys_mmap(desc_len + 16)
98 i = 0
99 while i < desc_len {
100 dbuf[i] = desc[i]
101 i = i + 1
102 }
103 dbuf[desc_len] = 0
104 t.desc = dbuf
105 t.desc_len = desc_len
106 t.created_ns = now_ns
107 t.updated_ns = now_ns
108 t.summary = 0 as *u8
109 t.summary_len = 0
110 t.blocked_by = 0 as *i64
111 t.n_blocked = 0
112 g.n_tasks = idx + 1
113 return idx
114}
115
116// === dependencies ===================================================
117
118// Mark `task_idx` as blocked by `blocker_idx`. Tasks become
119// ready (next_ready returns them) only when ALL blockers are
120// CLOSED. Returns 0 on success, -1 on invalid indices, -2 if
121// the dep cap is reached.
122func nxtask_add_dep(g: *TaskGraph, task_idx: i64,
123 blocker_idx: i64) -> i64 {
124 if task_idx < 0 { return -1 }
125 if task_idx >= g.n_tasks { return -1 }
126 if blocker_idx < 0 { return -1 }
127 if blocker_idx >= g.n_tasks { return -1 }
128 if task_idx == blocker_idx { return -1 } // self-cycle
129 let t: *Task = task_at(g, task_idx)
130 if t.blocked_by == (0 as *i64) {
131 t.blocked_by = sys_mmap(MAX_DEPS * 8 + 16) as *i64
132 t.n_blocked = 0
133 }
134 if t.n_blocked >= MAX_DEPS { return -2 }
135 t.blocked_by[t.n_blocked] = blocker_idx
136 t.n_blocked = t.n_blocked + 1
137 return 0
138}
139
140// Is this task ready to claim? A task is ready iff:
141// - state is OPEN
142// - every blocker is CLOSED
143func nxtask_is_ready(g: *TaskGraph, idx: i64) -> i64 {
144 if idx < 0 { return 0 }
145 if idx >= g.n_tasks { return 0 }
146 let t: *Task = task_at(g, idx)
147 if t.state != TASK_STATE_OPEN { return 0 }
148 var i: i64 = 0
149 while i < t.n_blocked {
150 let b_idx: i64 = t.blocked_by[i]
151 if b_idx >= 0 {
152 if b_idx < g.n_tasks {
153 let b: *Task = task_at(g, b_idx)
154 if b.state != TASK_STATE_CLOSED { return 0 }
155 }
156 }
157 i = i + 1
158 }
159 return 1
160}
161
162// === claim ==========================================================
163
164// Atomic claim: if task is open, mark it claimed by `agent`.
165// Returns 0 on success, -1 if already claimed or closed, -2 if
166// task index out of bounds.
167//
168// Atomicity note: v0.0.1 is single-process so the check-then-write
169// is trivially atomic from the caller's view. v0.1.0 adds proper
170// file-system-level locking via rename().
171func nxtask_claim(g: *TaskGraph, idx: i64,
172 agent: *u8, agent_len: i64,
173 now_ns: i64) -> i64 {
174 if idx < 0 { return -2 }
175 if idx >= g.n_tasks { return -2 }
176 let t: *Task = task_at(g, idx)
177 if t.state != TASK_STATE_OPEN { return -1 }
178 t.state = TASK_STATE_CLAIMED
179 let cbuf: *u8 = sys_mmap(agent_len + 16)
180 var i: i64 = 0
181 while i < agent_len {
182 cbuf[i] = agent[i]
183 i = i + 1
184 }
185 cbuf[agent_len] = 0
186 t.claimed_by = cbuf
187 t.claimed_by_len = agent_len
188 t.updated_ns = now_ns
189 return 0
190}
191
192// === close ==========================================================
193
194// Close a task with a completion summary. Returns 0 on success,
195// -1 if task wasn't claimed or already closed.
196func nxtask_close(g: *TaskGraph, idx: i64,
197 summary: *u8, summary_len: i64,
198 now_ns: i64) -> i64 {
199 if idx < 0 { return -2 }
200 if idx >= g.n_tasks { return -2 }
201 let t: *Task = task_at(g, idx)
202 if t.state == TASK_STATE_CLOSED { return -1 }
203 t.state = TASK_STATE_CLOSED
204 let sbuf: *u8 = sys_mmap(summary_len + 16)
205 var i: i64 = 0
206 while i < summary_len {
207 sbuf[i] = summary[i]
208 i = i + 1
209 }
210 sbuf[summary_len] = 0
211 t.summary = sbuf
212 t.summary_len = summary_len
213 t.updated_ns = now_ns
214 return 0
215}
216
217// === query ==========================================================
218
219// Count tasks in a given state.
220func nxtask_count_state(g: *TaskGraph, state: i64) -> i64 {
221 var n: i64 = 0
222 var i: i64 = 0
223 while i < g.n_tasks {
224 let t: *Task = task_at(g, i)
225 if t.state == state { n = n + 1 }
226 i = i + 1
227 }
228 return n
229}
230
231// Find the first ready task (OPEN with all blockers CLOSED) for
232// an agent to claim. Now respects the dependency graph.
233func nxtask_next_ready(g: *TaskGraph) -> i64 {
234 var i: i64 = 0
235 while i < g.n_tasks {
236 if nxtask_is_ready(g, i) == 1 { return i }
237 i = i + 1
238 }
239 return -1
240}
241
242// === self-test ======================================================
243
244func main() -> i64 {
245 let g: *TaskGraph = nxtask_graph_new(16)
246
247 let d1: *u8 = "Port regalloc to runtime/regalloc.nx" as *u8
248 let id1: i64 = nxtask_add(g, d1, 37, 1000000000)
249 if id1 != 0 { return __syscall(93, 50, 0, 0, 0, 0, 0) }
250
251 let d2: *u8 = "Add F-extension codegen" as *u8
252 let id2: i64 = nxtask_add(g, d2, 24, 1000001000)
253 if id2 != 1 { return __syscall(93, 51, 0, 0, 0, 0, 0) }
254
255 // Counts.
256 if g.n_tasks != 2 { return __syscall(93, 52, 0, 0, 0, 0, 0) }
257 if nxtask_count_state(g, TASK_STATE_OPEN) != 2 {
258 return __syscall(93, 53, 0, 0, 0, 0, 0)
259 }
260
261 // Ready task: first one.
262 let next_id: i64 = nxtask_next_ready(g)
263 if next_id != 0 { return __syscall(93, 54, 0, 0, 0, 0, 0) }
264
265 // Claim + verify.
266 let agent: *u8 = "mormon-v0" as *u8
267 let rc: i64 = nxtask_claim(g, 0, agent, 9, 1000002000)
268 if rc != 0 { return __syscall(93, 55, 0, 0, 0, 0, 0) }
269 let t0: *Task = task_at(g, 0)
270 if t0.state != TASK_STATE_CLAIMED {
271 return __syscall(93, 56, 0, 0, 0, 0, 0)
272 }
273
274 // Re-claiming fails.
275 let rc2: i64 = nxtask_claim(g, 0, agent, 9, 1000003000)
276 if rc2 != -1 { return __syscall(93, 57, 0, 0, 0, 0, 0) }
277
278 // Next ready skips claimed task.
279 let next_id2: i64 = nxtask_next_ready(g)
280 if next_id2 != 1 { return __syscall(93, 58, 0, 0, 0, 0, 0) }
281
282 // Close with summary.
283 let smry: *u8 = "Done: rematerialisation ported to runtime" as *u8
284 let rc3: i64 = nxtask_close(g, 0, smry, 41, 1000005000)
285 if rc3 != 0 { return __syscall(93, 59, 0, 0, 0, 0, 0) }
286 if nxtask_count_state(g, TASK_STATE_CLOSED) != 1 {
287 return __syscall(93, 60, 0, 0, 0, 0, 0)
288 }
289
290 // Dependency test: add a 3rd task that depends on task 1
291 // (which is still OPEN). Task 1 is ready; task 2 is blocked.
292 let d3: *u8 = "Wire F-extension codegen" as *u8
293 let id3: i64 = nxtask_add(g, d3, 24, 1000006000)
294 if id3 != 2 { return __syscall(93, 61, 0, 0, 0, 0, 0) }
295 let drc: i64 = nxtask_add_dep(g, id3, 1)
296 if drc != 0 { return __syscall(93, 62, 0, 0, 0, 0, 0) }
297
298 // Task 1 is ready (no blockers).
299 if nxtask_is_ready(g, 1) != 1 {
300 return __syscall(93, 63, 0, 0, 0, 0, 0)
301 }
302 // Task 2 is blocked by task 1 (which isn't closed yet).
303 if nxtask_is_ready(g, 2) != 0 {
304 return __syscall(93, 64, 0, 0, 0, 0, 0)
305 }
306 // next_ready returns task 1 (the only ready one).
307 let nxt: i64 = nxtask_next_ready(g)
308 if nxt != 1 {
309 return __syscall(93, 65, 0, 0, 0, 0, 0)
310 }
311
312 // Close task 1. Now task 2 is ready.
313 let smry2: *u8 = "F-ext scaffolded" as *u8
314 nxtask_close(g, 1, smry2, 16, 1000007000)
315 if nxtask_is_ready(g, 2) != 1 {
316 return __syscall(93, 66, 0, 0, 0, 0, 0)
317 }
318
319 return __syscall(93, 42, 0, 0, 0, 0, 0)
320}