parallelclaw 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  Notable changes to parallelclaw (formerly memex-mvp). Older history lives in the git log.
4
4
 
5
+ ## 1.1.0 — agent coordination: hardened task ledger + executor + result-routing
6
+
7
+ The first release where the **coordination layer works end-to-end**: one of your
8
+ agents delegates a task, an always-on agent runs it, and the answer comes back to
9
+ where you asked — riding the existing sync mesh, no new transport.
10
+
11
+ ### Added
12
+ - **`task-claim <id>`** — atomically claim a submitted task for execution (exit 0
13
+ claimed / 3 not-claimable / 1 error; `--json`). The always-on executor uses it
14
+ so two pollers never double-run one task. See `docs/design/executor-prompt.md`
15
+ for the ready-to-paste cron-prompt.
16
+ - **`task-results [--ack] [--json] [--all]`** — results of tasks you delegated,
17
+ to deliver to your surface. The SessionStart hook now injects a "📥 N results
18
+ from tasks you delegated" block automatically (Claude Code requester); a
19
+ Telegram requester's cron DMs them and acks after sending.
20
+
21
+ ### Changed / fixed (ledger correctness — required before unattended execution)
22
+ - **Skew-proof status (#1):** current task state is decided by a monotonic
23
+ per-task `seq` (then lifecycle-rank → ts → rowid), not wall-clock ts. Clock
24
+ skew across nodes can no longer regress a `done` back to `working`.
25
+ - **Claim/lock (#3):** optimistic claim resolves concurrent take attempts to one
26
+ deterministic winner once synced.
27
+ - **#2** a `to:'any'` task lands in every node's inbox (first to claim wins).
28
+ **#4** unique per-event `msg_id` (no same-ms silent drop). **#5** lifecycle
29
+ guard (no un-completing a terminal, no regress to submitted). **#6** `done`/
30
+ `failed` must carry a result.
31
+
32
+ Deferred: #7 `listTasks` scan-bound + index (perf), #8 excluding `agent-task`
33
+ rows from default search/overview. NL delegate tool (2c) and the Mac headless
34
+ `claude -p` runner (2b) are the next slices.
35
+
5
36
  ## 1.0.0 — rebrand: memex → ParallelClaw
6
37
 
7
38
  The product is renamed **ParallelClaw** and repositioned from "AI memory" to a
package/ingest.js CHANGED
@@ -156,7 +156,9 @@ if (subcommand && subcommand !== '--help' && subcommand.startsWith('-') === fals
156
156
  // Foreman tracer (v0) — agent-to-agent task ledger (tasks = agent-task messages)
157
157
  'task-delegate': async () => (await import('./lib/tasks.js')).cmdTaskDelegate(),
158
158
  'task-list': async () => (await import('./lib/tasks.js')).cmdTaskList(),
159
+ 'task-claim': async () => (await import('./lib/tasks.js')).cmdTaskClaim(),
159
160
  'task-update': async () => (await import('./lib/tasks.js')).cmdTaskUpdate(),
161
+ 'task-results': async () => (await import('./lib/tasks.js')).cmdTaskResults(),
160
162
  serve: cmdServe, // explicit foreground; same as no-arg
161
163
  // All scan / export modes fall through to module-level logic at EOF.
162
164
  // cmdServe is a no-op marker so the dispatch doesn't error.
package/lib/cli/index.js CHANGED
@@ -799,6 +799,14 @@ async function cmdContext(args) {
799
799
  pendingTg = listPending();
800
800
  } catch (_) { /* ignore */ }
801
801
 
802
+ // Result-routing (Phase 2d): answers to tasks THIS node delegated that are now
803
+ // terminal and not yet surfaced here — "answer where you asked".
804
+ let taskResults = [];
805
+ try {
806
+ const { listTaskResults } = await import('../tasks.js');
807
+ taskResults = listTaskResults({ undeliveredOnly: true });
808
+ } catch (_) { /* ignore */ }
809
+
802
810
  if (opts.json) {
803
811
  console.log(JSON.stringify({
804
812
  pwd, project, freshness_days: freshnessDays,
@@ -806,6 +814,7 @@ async function cmdContext(args) {
806
814
  fuzzy_matches: filteredFuzzy.length,
807
815
  conversations: all,
808
816
  token_budget: tokenBudget,
817
+ task_results: taskResults,
809
818
  telegram_pending: pendingTg.map((e) => ({
810
819
  chat_title: e.chat_title,
811
820
  chat_type: e.chat_type,
@@ -846,7 +855,21 @@ async function cmdContext(args) {
846
855
  lines.push('');
847
856
  }
848
857
 
849
- if (all.length === 0 && pendingTg.length === 0) {
858
+ if (taskResults.length > 0) {
859
+ lines.push(`### 📥 ${taskResults.length} result${taskResults.length === 1 ? '' : 's'} from tasks you delegated`);
860
+ lines.push('');
861
+ for (const t of taskResults.slice(0, 10)) {
862
+ const icon = t.status === 'done' ? '✓' : '✗';
863
+ lines.push(`- ${icon} **${t.id}** → ${t.to} _(${t.status})_ — ${String(t.prompt || '').slice(0, 70)}`);
864
+ lines.push(` - result: ${String(t.result || '').slice(0, 220)}`);
865
+ }
866
+ if (taskResults.length > 10) lines.push(`- _… and ${taskResults.length - 10} more (run \`memex-sync task-results\`)_`);
867
+ lines.push('');
868
+ lines.push(`**INSTRUCTION FOR THE AGENT:** these are answers to tasks you handed to another of your agents. Surface them to the user briefly, then continue.`);
869
+ lines.push('');
870
+ }
871
+
872
+ if (all.length === 0 && pendingTg.length === 0 && taskResults.length === 0) {
850
873
  lines.push(`_No recent activity in memex for this project (last ${freshnessDays} days)._`);
851
874
  lines.push('');
852
875
  lines.push(`Path searched: \`${pwd}\``);
@@ -895,6 +918,11 @@ async function cmdContext(args) {
895
918
  out += '\n\n_[context truncated — exceeds token budget]_\n';
896
919
  }
897
920
 
921
+ // Mark surfaced results delivered so they don't re-show every session. Skip on
922
+ // --no-ack (preview/testing) — json/help paths returned earlier and never ack.
923
+ if (taskResults.length > 0 && !(opts.noAck || opts['no-ack'])) {
924
+ try { const { markResultsDelivered } = await import('../tasks.js'); markResultsDelivered(taskResults.map((t) => t.id)); } catch (_) { /* re-show next time */ }
925
+ }
898
926
  process.stdout.write(out);
899
927
  }
900
928
 
package/lib/tasks.js CHANGED
@@ -24,13 +24,31 @@
24
24
  */
25
25
 
26
26
  import { homedir } from 'node:os';
27
- import { join } from 'node:path';
27
+ import { join, dirname } from 'node:path';
28
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
28
29
  import Database from 'better-sqlite3';
29
30
 
30
31
  import { getOrigin } from './config.js';
31
32
 
32
33
  const SOURCE = 'agent-task';
33
34
  const STATUSES = ['submitted', 'working', 'done', 'failed'];
35
+ const TERMINAL = new Set(['done', 'failed']);
36
+
37
+ // Pick the CURRENT event of a task WITHOUT trusting wall-clock ts — clock skew
38
+ // across nodes regressed state on the live mesh (a 'done' on a fast clock could
39
+ // sort before a later 'working'). Order by the monotonic per-task `seq`, then a
40
+ // lifecycle rank (terminal wins a seq-tie, so a concurrent 'working' can never
41
+ // un-complete a 'done'), then ts, then rowid — a total, deterministic order
42
+ // every node computes identically once synced.
43
+ const STATUS_RANK = { submitted: 0, working: 1, failed: 2, done: 3 };
44
+ function cmpEvent(a, b) {
45
+ const sa = a.seq ?? 0, sb = b.seq ?? 0;
46
+ if (sa !== sb) return sa - sb;
47
+ const ra = STATUS_RANK[a.status] ?? -1, rb = STATUS_RANK[b.status] ?? -1;
48
+ if (ra !== rb) return ra - rb;
49
+ if ((a.ts || 0) !== (b.ts || 0)) return (a.ts || 0) - (b.ts || 0);
50
+ return (a.rowid || 0) - (b.rowid || 0);
51
+ }
34
52
 
35
53
  function dbPath() {
36
54
  const dir = process.env.MEMEX_DIR || join(homedir(), '.memex');
@@ -45,9 +63,12 @@ function openDb() {
45
63
  }
46
64
 
47
65
  /** Append one event row for a task. Returns the row's msg_id. */
48
- function writeEvent(db, { taskId, status, envelope, text, origin, now }) {
66
+ function writeEvent(db, { taskId, status, seq, envelope, text, origin, now }) {
49
67
  const ts = Math.floor(now / 1000);
50
- const msgId = `${taskId}.${status}.${now}`;
68
+ // `seq` makes the id unique per event (fixes the same-status-same-ms silent
69
+ // INSERT-OR-IGNORE drop); `origin` disambiguates concurrent cross-node writes
70
+ // at the same seq. Computed once at write time → stable across sync.
71
+ const msgId = `${taskId}.${seq}.${status}.${origin}.${now}`;
51
72
  db.prepare(
52
73
  `INSERT OR IGNORE INTO messages
53
74
  (source, conversation_id, msg_id, role, sender, text, ts, metadata, origin)
@@ -70,9 +91,9 @@ export function createTask({ prompt, to = 'any', kind = 'general', content = nul
70
91
  const id = `t${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
71
92
  try {
72
93
  writeEvent(db, {
73
- taskId: id, status: 'submitted', origin: from, now,
94
+ taskId: id, status: 'submitted', seq: 1, origin: from, now,
74
95
  text: String(prompt),
75
- envelope: { task_id: id, status: 'submitted', from, to, kind,
96
+ envelope: { task_id: id, status: 'submitted', seq: 1, from, to, kind,
76
97
  prompt: String(prompt), content: content || null, result: null },
77
98
  });
78
99
  } finally { if (ownDb) db.close(); }
@@ -91,26 +112,65 @@ export function updateTask(id, status, { result = null, db = null, now = Date.no
91
112
  try {
92
113
  const prev = latestEvent(db, id);
93
114
  if (!prev) throw new Error(`updateTask: no task "${id}"`);
94
- const env = { ...prev.envelope, status, result: result ?? prev.envelope.result ?? null };
115
+ const prevStatus = prev.envelope.status;
116
+ // #5 lifecycle guard: never un-complete a terminal task, never regress to submitted.
117
+ if (TERMINAL.has(prevStatus)) throw new Error(`updateTask: task "${id}" is already ${prevStatus} (terminal) — refusing to set ${status}`);
118
+ if (status === 'submitted') throw new Error(`updateTask: cannot move task "${id}" back to submitted`);
119
+ // #6 a terminal state must say why/what — no silent empty done/failed.
120
+ const carried = result ?? prev.envelope.result ?? null;
121
+ if (TERMINAL.has(status) && !(carried && String(carried).trim())) {
122
+ throw new Error(`updateTask: '${status}' requires a result — pass { result: "<output or reason>" }`);
123
+ }
124
+ const seq = (prev.envelope.seq ?? 0) + 1;
125
+ const env = { ...prev.envelope, status, seq, result: carried };
95
126
  writeEvent(db, {
96
- taskId: id, status, origin: getOrigin(), now,
97
- text: status === 'done' || status === 'failed' ? (result || '') : (env.prompt || ''),
127
+ taskId: id, status, seq, origin: getOrigin(), now,
128
+ text: TERMINAL.has(status) ? (result || carried || '') : (env.prompt || ''),
98
129
  envelope: env,
99
130
  });
100
131
  return env;
101
132
  } finally { if (ownDb) db.close(); }
102
133
  }
103
134
 
104
- /** Latest event for one task id, or null. Returns { envelope, ts, origin }. */
135
+ /**
136
+ * Atomically CLAIM a submitted task for execution — prevents double-execution
137
+ * when several executors/cron-ticks poll the same inbox (#3). Optimistic: write
138
+ * a 'working' event stamped with my origin, then re-read — I hold the claim only
139
+ * if the current event is MY working (cmpEvent picks one deterministic winner
140
+ * across nodes once synced). Returns the working envelope on success, or null if
141
+ * the task wasn't claimable (already taken / terminal) or another node won.
142
+ * opts: { db?, now? }.
143
+ */
144
+ export function claimTask(id, { db = null, now = Date.now() } = {}) {
145
+ const ownDb = !db;
146
+ db = db || openDb();
147
+ try {
148
+ const me = getOrigin();
149
+ const prev = latestEvent(db, id);
150
+ if (!prev) throw new Error(`claimTask: no task "${id}"`);
151
+ if (prev.envelope.status !== 'submitted') return null; // already working/terminal — not claimable
152
+ const seq = (prev.envelope.seq ?? 0) + 1;
153
+ const env = { ...prev.envelope, status: 'working', seq, result: prev.envelope.result ?? null };
154
+ writeEvent(db, { taskId: id, status: 'working', seq, origin: me, now, text: env.prompt || '', envelope: env });
155
+ const after = latestEvent(db, id);
156
+ if (after && after.envelope.status === 'working' && after.origin === me) return after.envelope;
157
+ return null; // another executor's working won the race
158
+ } finally { if (ownDb) db.close(); }
159
+ }
160
+
161
+ /** Current event for one task id, or null. Returns { envelope, ts, origin }. */
105
162
  function latestEvent(db, id) {
106
163
  const rows = db.prepare(
107
- `SELECT metadata, ts, origin FROM messages
108
- WHERE source = ? AND conversation_id = ? ORDER BY ts DESC, id DESC LIMIT 1`
164
+ `SELECT id AS rowid, metadata, ts, origin FROM messages
165
+ WHERE source = ? AND conversation_id = ? ORDER BY ts ASC, id ASC`
109
166
  ).all(SOURCE, `task-${id}`);
110
- if (!rows.length) return null;
111
- let envelope = {};
112
- try { envelope = JSON.parse(rows[0].metadata || '{}'); } catch (_) {}
113
- return { envelope, ts: rows[0].ts, origin: rows[0].origin };
167
+ let best = null;
168
+ for (const r of rows) {
169
+ let env; try { env = JSON.parse(r.metadata || '{}'); } catch (_) { continue; }
170
+ const cand = { envelope: env, ts: r.ts, origin: r.origin, status: env.status, seq: env.seq, rowid: r.rowid };
171
+ if (!best || cmpEvent(cand, best) > 0) best = cand;
172
+ }
173
+ return best ? { envelope: best.envelope, ts: best.ts, origin: best.origin } : null;
114
174
  }
115
175
 
116
176
  /**
@@ -128,17 +188,20 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
128
188
  // All events; reduce to latest per task in JS (low volume; robust to
129
189
  // cross-node id ordering — we key on logical ts, not local rowid).
130
190
  const rows = db.prepare(
131
- `SELECT conversation_id, metadata, ts FROM messages
191
+ `SELECT id AS rowid, conversation_id, metadata, ts FROM messages
132
192
  WHERE source = ? ORDER BY ts ASC, id ASC`
133
193
  ).all(SOURCE);
134
194
  const latest = new Map();
135
195
  for (const r of rows) {
136
196
  let env; try { env = JSON.parse(r.metadata || '{}'); } catch (_) { continue; }
137
197
  if (!env.task_id) continue;
138
- latest.set(env.task_id, { ...env, ts: r.ts }); // later rows overwrite → latest wins
198
+ const cand = { ...env, ts: r.ts, rowid: r.rowid };
199
+ const cur = latest.get(env.task_id);
200
+ if (!cur || cmpEvent(cand, cur) > 0) latest.set(env.task_id, cand); // skew-proof: seq, not rowid
139
201
  }
140
202
  let out = [...latest.values()];
141
- if (inbox) out = out.filter((t) => (t.to === me) && t.status === 'submitted');
203
+ // #2 'any' is a broadcast: it lands in EVERY node's inbox (claim resolves the winner).
204
+ if (inbox) out = out.filter((t) => (t.to === me || t.to === 'any') && t.status === 'submitted');
142
205
  if (mine) out = out.filter((t) => t.from === me);
143
206
  if (forOrigin) out = out.filter((t) => t.to === forOrigin || t.from === forOrigin);
144
207
  if (status) out = out.filter((t) => t.status === status);
@@ -150,6 +213,48 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
150
213
  } finally { if (ownDb) db.close(); }
151
214
  }
152
215
 
216
+ // ── Result-routing (Phase 2d) ────────────────────────────────────────────────
217
+ // "Answer where you asked": a requester surfaces the results of tasks IT
218
+ // delegated once they're terminal. "Delivered" is per-node UX state (which
219
+ // results this surface already showed) — kept LOCAL in a small JSON file, NOT
220
+ // in the synced ledger (delivery is surface-specific, not task state).
221
+
222
+ function seenPath() {
223
+ const dir = process.env.MEMEX_DIR || join(homedir(), '.memex');
224
+ return join(dir, 'task-results-seen.json');
225
+ }
226
+ function readDelivered() {
227
+ try { return new Set(JSON.parse(readFileSync(seenPath(), 'utf-8')).delivered || []); }
228
+ catch (_) { return new Set(); }
229
+ }
230
+ /** Mark task ids as delivered to this node's surface (idempotent). */
231
+ export function markResultsDelivered(ids = []) {
232
+ if (!ids || !ids.length) return;
233
+ const set = readDelivered();
234
+ for (const id of ids) set.add(id);
235
+ try {
236
+ const p = seenPath();
237
+ mkdirSync(dirname(p), { recursive: true });
238
+ writeFileSync(p, JSON.stringify({ delivered: [...set] }));
239
+ } catch (_) { /* best-effort; re-show next time rather than crash */ }
240
+ }
241
+
242
+ /**
243
+ * Results to route back to a requester surface: tasks THIS origin delegated
244
+ * (from === forOrigin) that are now terminal (done/failed), newest-first.
245
+ * undeliveredOnly drops ids already shown on this node. opts: { forOrigin, undeliveredOnly, db? }.
246
+ */
247
+ export function listTaskResults({ forOrigin = null, undeliveredOnly = false, db = null } = {}) {
248
+ const ownDb = !db;
249
+ db = db || openDb();
250
+ try {
251
+ const me = forOrigin || getOrigin();
252
+ let out = listTasks({ db, limit: 1000 }).filter((t) => t.from === me && TERMINAL.has(t.status));
253
+ if (undeliveredOnly) { const seen = readDelivered(); out = out.filter((t) => !seen.has(t.id)); }
254
+ return out;
255
+ } finally { if (ownDb) db.close(); }
256
+ }
257
+
153
258
  // ── CLI ──────────────────────────────────────────────────────────────────────
154
259
 
155
260
  function parseFlags(argv) {
@@ -194,6 +299,32 @@ export function cmdTaskUpdate() {
194
299
  process.exit(0);
195
300
  }
196
301
 
302
+ export function cmdTaskClaim() {
303
+ const args = parseFlags(process.argv.slice(3));
304
+ const id = (args._ || [])[0];
305
+ if (!id) {
306
+ console.error('usage: task-claim <id> [--json] (atomically take a submitted task for execution)');
307
+ process.exit(2);
308
+ }
309
+ let env;
310
+ try { env = claimTask(id); }
311
+ catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
312
+ if (!env) {
313
+ // exit 3 = "not claimable" (already taken / terminal / lost the race) — the
314
+ // executor loop should treat this as "skip", NOT an error.
315
+ if ('--json' in args) console.log(JSON.stringify({ claimed: false, id }));
316
+ else console.log(`· task ${id} not claimable (already taken or terminal) — skip`);
317
+ process.exit(3);
318
+ }
319
+ if ('--json' in args) { console.log(JSON.stringify({ claimed: true, id, ...env })); process.exit(0); }
320
+ console.log(`✓ claimed ${id} → working (by ${getOrigin()})`);
321
+ console.log(` from ${env.from} · kind: ${env.kind || 'general'}`);
322
+ console.log(` prompt: ${env.prompt || ''}`);
323
+ if (env.content) console.log(` content: ${env.content}`);
324
+ console.log(` on finish: task-update ${id} done --result "<output>" (or: failed --result "<why>")`);
325
+ process.exit(0);
326
+ }
327
+
197
328
  export function cmdTaskList() {
198
329
  const args = parseFlags(process.argv.slice(3));
199
330
  const tasks = listTasks({
@@ -213,3 +344,28 @@ export function cmdTaskList() {
213
344
  }
214
345
  process.exit(0);
215
346
  }
347
+
348
+ // task-results [--ack] [--json] [--all]
349
+ // Results of tasks I delegated, to deliver to my surface (Phase 2d). The
350
+ // requester surface calls this: Claude Code via the SessionStart hook (auto),
351
+ // an OpenClaw/Telegram requester via a cron-prompt that DMs them. --ack marks
352
+ // them delivered so they don't re-show; --all ignores the delivered set.
353
+ export function cmdTaskResults() {
354
+ const args = parseFlags(process.argv.slice(3));
355
+ const results = listTaskResults({ undeliveredOnly: !('--all' in args) });
356
+ if ('--json' in args) {
357
+ console.log(JSON.stringify(results));
358
+ if ('--ack' in args) markResultsDelivered(results.map((r) => r.id));
359
+ process.exit(0);
360
+ }
361
+ if (!results.length) { console.log('No new task results.'); process.exit(0); }
362
+ console.log(`📥 ${results.length} result(s) from tasks you delegated:`);
363
+ for (const t of results) {
364
+ const icon = t.status === 'done' ? '✓' : '✗';
365
+ console.log(`${icon} ${t.id} → ${t.to} [${t.status}]`);
366
+ console.log(` task: ${String(t.prompt || '').slice(0, 80)}`);
367
+ console.log(` result: ${String(t.result || '').slice(0, 200)}`);
368
+ }
369
+ if ('--ack' in args) { markResultsDelivered(results.map((r) => r.id)); console.log(`(${results.length} marked delivered)`); }
370
+ process.exit(0);
371
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallelclaw",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Local-first personal AI ops layer. Shared verbatim memory across your agents (Claude Code, Cursor, OpenClaw, Hermes, Obsidian, Telegram) in one SQLite + FTS5 corpus — plus a coordination layer where any of your agents can delegate tasks to any other. Searchable from any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "server.js",