parallelclaw 1.2.1 → 1.3.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,46 @@
2
2
 
3
3
  Notable changes to parallelclaw (formerly memex-mvp). Older history lives in the git log.
4
4
 
5
+ ## 1.3.0 — instant delegation: push-on-write (no waiting for the sync schedule)
6
+
7
+ Delegations, claims, and results now fly to the mesh the moment they're written,
8
+ instead of waiting up to a full sync interval. Every task write on an initiating
9
+ node fires a detached `sync-run --all` immediately — fire-and-forget,
10
+ best-effort, a no-op when sync isn't configured (or `PARALLELCLAW_NO_PUSH=1`).
11
+ Wired into `task-delegate` / `task-claim` / `task-update`, the
12
+ `parallelclaw_delegate` MCP tool, and the Mac headless executor's results.
13
+ Live-verified: a delegation pushed to the hub in ~1s (last-sync jumped from 82m
14
+ ago to 0m ago) with no manual sync. +1 guard test.
15
+
16
+ This removes the **requester→hub** leg of the latency. Two legs remain
17
+ schedule-bound (tuned by interval, not code): the **hub→executor** forward (the
18
+ hub syncs the executor on its own timer) and the **executor's inbox poll**. Drop
19
+ those to ~30–60s for near-instant end-to-end; true sub-second needs hub
20
+ push-routing + an event-driven executor (next).
21
+
22
+ Also: **`task-list --inbox --quiet`** — a no-output exit-code gate (0 = has work,
23
+ 1 = empty) so a 1-min executor cron can pre-check with a free shell call and only
24
+ spin up the (token-costly) LLM agentTurn when a task is actually waiting.
25
+
26
+ ## 1.2.2 — durable execution: claim lease + auto-requeue for dead executors
27
+
28
+ Found live: a research task outran Kimi's 240-second cron tick. The executor had
29
+ already written `working` (the claim), but its agentTurn was killed before it
30
+ could write a terminal event → the task was stranded in `working` forever. Fixes:
31
+ - **Claim lease + lazy auto-requeue (#3b).** A `working` claim is leased (default
32
+ 15 min). A stale `working` (claimer died — cron timeout, crash, laptop sleep)
33
+ becomes claimable again and reappears in `task-list --inbox`, so the next
34
+ poller retries it. No task is lost to a dead executor, and no sweeper is needed.
35
+ `claimTask`/`listTasks` gain `now`/`leaseMs` knobs; the Mac executor passes them.
36
+ - **Executor prompt + docs:** read the FULL prompt from the `task-claim` output
37
+ (or `task-claim <id> --json`), never the 80-char `task-list` display line; on a
38
+ too-big task write `failed` with partial progress instead of spinning until the
39
+ run is killed or closing `done` with a placeholder (which would block requeue —
40
+ the exact thing that lost the first research delegation).
41
+
42
+ Tests: +2 (stale working is re-claimable and back in inbox; terminal never
43
+ requeues). Full npm test green.
44
+
5
45
  ## 1.2.1 — patch: make the Mac executor's `claude -p` actually complete
6
46
 
7
47
  The Phase 2b headless executor invoked `claude -p` but every run hung until the
package/lib/executor.js CHANGED
@@ -25,7 +25,7 @@ import { writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
25
25
  import { execFile, execSync } from 'node:child_process';
26
26
 
27
27
  import { getOrigin } from './config.js';
28
- import { listTasks, claimTask, updateTask } from './tasks.js';
28
+ import { listTasks, claimTask, updateTask, triggerSyncPush } from './tasks.js';
29
29
 
30
30
  const HOME = homedir();
31
31
  const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
@@ -104,7 +104,9 @@ export async function runInboxOnce({
104
104
  const me = getOrigin();
105
105
  const exec = runner || claudeRunner;
106
106
  const acted = [];
107
- let inbox = listTasks({ inbox: true, db, limit: 50 });
107
+ // Passing `now` keeps the lease/stale-working check on the same clock as claimTask
108
+ // below, and lets the executor pick up tasks abandoned by a dead claimer (#3b).
109
+ let inbox = listTasks({ inbox: true, db, limit: 50, now });
108
110
  inbox = inbox.filter((t) => t.to === me || (includeBroadcast && t.to === 'any'));
109
111
  for (const t of inbox) {
110
112
  if (!allowKinds.includes(t.kind)) {
@@ -126,6 +128,9 @@ export async function runInboxOnce({
126
128
  acted.push({ id: t.id, status: 'failed' });
127
129
  }
128
130
  }
131
+ // Fly results back to the requester now (claimTask/updateTask here are lib-level
132
+ // and don't push on their own). One push covers the whole drain pass.
133
+ if (acted.some((a) => a.status === 'done' || a.status === 'failed')) triggerSyncPush();
129
134
  return acted;
130
135
  }
131
136
 
package/lib/tasks.js CHANGED
@@ -27,13 +27,27 @@ import { homedir } from 'node:os';
27
27
  import { join, dirname } from 'node:path';
28
28
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
29
29
  import Database from 'better-sqlite3';
30
+ import { spawn } from 'node:child_process';
31
+ import { fileURLToPath } from 'node:url';
30
32
 
31
33
  import { getOrigin } from './config.js';
34
+ import { syncExperimentEnabled } from './sync/config.js';
32
35
 
33
36
  const SOURCE = 'agent-task';
34
37
  const STATUSES = ['submitted', 'working', 'done', 'failed'];
35
38
  const TERMINAL = new Set(['done', 'failed']);
36
39
 
40
+ // A claimed ('working') task whose claimer dies (cron agentTurn timeout, crash,
41
+ // laptop sleep) would otherwise be stranded in 'working' forever. We LEASE the
42
+ // claim: a 'working' event older than LEASE_MS is treated as abandoned and
43
+ // becomes re-claimable by the next poller (lazy requeue — no sweeper, no extra
44
+ // event type). Generous so modest cross-node clock skew can't expire a LIVE
45
+ // claim. Tunable per call via opts.leaseMs.
46
+ const LEASE_MS = 15 * 60 * 1000;
47
+ function isStaleWorking(status, eventTsSec, nowMs, leaseMs) {
48
+ return status === 'working' && (nowMs - (eventTsSec || 0) * 1000) > leaseMs;
49
+ }
50
+
37
51
  // Pick the CURRENT event of a task WITHOUT trusting wall-clock ts — clock skew
38
52
  // across nodes regressed state on the live mesh (a 'done' on a fast clock could
39
53
  // sort before a later 'working'). Order by the monotonic per-task `seq`, then a
@@ -62,6 +76,26 @@ function openDb() {
62
76
  return db;
63
77
  }
64
78
 
79
+ const INGEST_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'ingest.js');
80
+
81
+ /**
82
+ * Push the just-written task event toward the mesh NOW — so a delegation, claim,
83
+ * or result propagates in seconds instead of waiting for the next scheduled sync.
84
+ * Fire-and-forget: detached, best-effort, never blocks or throws. No-op when sync
85
+ * isn't configured (or when disabled via PARALLELCLAW_NO_PUSH=1). Returns the
86
+ * spawned child (or null if skipped) — for tests.
87
+ */
88
+ export function triggerSyncPush() {
89
+ try {
90
+ if (process.env.PARALLELCLAW_NO_PUSH === '1') return null;
91
+ if (!syncExperimentEnabled()) return null; // no mesh configured → nothing to push to
92
+ const child = spawn(process.execPath, [INGEST_PATH, 'sync-run', '--all'],
93
+ { detached: true, stdio: 'ignore', env: process.env });
94
+ child.unref();
95
+ return child;
96
+ } catch (_) { return null; }
97
+ }
98
+
65
99
  /** Append one event row for a task. Returns the row's msg_id. */
66
100
  function writeEvent(db, { taskId, status, seq, envelope, text, origin, now }) {
67
101
  const ts = Math.floor(now / 1000);
@@ -141,14 +175,18 @@ export function updateTask(id, status, { result = null, db = null, now = Date.no
141
175
  * the task wasn't claimable (already taken / terminal) or another node won.
142
176
  * opts: { db?, now? }.
143
177
  */
144
- export function claimTask(id, { db = null, now = Date.now() } = {}) {
178
+ export function claimTask(id, { db = null, now = Date.now(), leaseMs = LEASE_MS } = {}) {
145
179
  const ownDb = !db;
146
180
  db = db || openDb();
147
181
  try {
148
182
  const me = getOrigin();
149
183
  const prev = latestEvent(db, id);
150
184
  if (!prev) throw new Error(`claimTask: no task "${id}"`);
151
- if (prev.envelope.status !== 'submitted') return null; // already working/terminal — not claimable
185
+ const status = prev.envelope.status;
186
+ // Claimable if fresh-submitted OR a STALE 'working' (its claimer died — the
187
+ // lease expired). A fresh 'working' or a terminal state is not claimable.
188
+ const reclaimStale = isStaleWorking(status, prev.ts, now, leaseMs);
189
+ if (status !== 'submitted' && !reclaimStale) return null;
152
190
  const seq = (prev.envelope.seq ?? 0) + 1;
153
191
  const env = { ...prev.envelope, status: 'working', seq, result: prev.envelope.result ?? null };
154
192
  writeEvent(db, { taskId: id, status: 'working', seq, origin: me, now, text: env.prompt || '', envelope: env });
@@ -180,7 +218,7 @@ function latestEvent(db, id) {
180
218
  * inbox — tasks addressed to me AND currently 'submitted' (ready to take)
181
219
  * Returns [{ id, from, to, kind, status, prompt, result, ts }] newest-first.
182
220
  */
183
- export function listTasks({ forOrigin = null, status = null, mine = false, inbox = false, limit = 50, db = null } = {}) {
221
+ export function listTasks({ forOrigin = null, status = null, mine = false, inbox = false, limit = 50, db = null, now = Date.now(), leaseMs = LEASE_MS } = {}) {
184
222
  const ownDb = !db;
185
223
  db = db || openDb();
186
224
  try {
@@ -201,7 +239,9 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
201
239
  }
202
240
  let out = [...latest.values()];
203
241
  // #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');
242
+ // inbox = ready to run: 'submitted', OR a stale 'working' whose claimer died.
243
+ if (inbox) out = out.filter((t) => (t.to === me || t.to === 'any')
244
+ && (t.status === 'submitted' || isStaleWorking(t.status, t.ts, now, leaseMs)));
205
245
  if (mine) out = out.filter((t) => t.from === me);
206
246
  if (forOrigin) out = out.filter((t) => t.to === forOrigin || t.from === forOrigin);
207
247
  if (status) out = out.filter((t) => t.status === status);
@@ -282,6 +322,7 @@ export function cmdTaskDelegate() {
282
322
  });
283
323
  console.log(`✓ delegated task ${id} → ${to} (from ${getOrigin()})`);
284
324
  console.log(` track: task-list --mine`);
325
+ triggerSyncPush(); // fly it to the mesh now, don't wait for the schedule
285
326
  process.exit(0);
286
327
  }
287
328
 
@@ -296,6 +337,7 @@ export function cmdTaskUpdate() {
296
337
  const env = updateTask(id, status, { result: typeof args['--result'] === 'string' ? args['--result'] : null });
297
338
  console.log(`✓ task ${id} → ${env.status}${env.result ? ' (result attached)' : ''}`);
298
339
  } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
340
+ triggerSyncPush(); // status transition flies back to the requester now
299
341
  process.exit(0);
300
342
  }
301
343
 
@@ -316,6 +358,7 @@ export function cmdTaskClaim() {
316
358
  else console.log(`· task ${id} not claimable (already taken or terminal) — skip`);
317
359
  process.exit(3);
318
360
  }
361
+ triggerSyncPush(); // propagate the claim immediately (shrinks the double-claim window)
319
362
  if ('--json' in args) { console.log(JSON.stringify({ claimed: true, id, ...env })); process.exit(0); }
320
363
  console.log(`✓ claimed ${id} → working (by ${getOrigin()})`);
321
364
  console.log(` from ${env.from} · kind: ${env.kind || 'general'}`);
@@ -334,6 +377,10 @@ export function cmdTaskList() {
334
377
  inbox: '--inbox' in args,
335
378
  limit: parseInt(args['--limit'] || '', 10) || 50,
336
379
  });
380
+ // Cheap exit-code gate for cron polling: no output, exit 0 if there ARE tasks,
381
+ // 1 if none. Lets a scheduler do `task-list --inbox --quiet && run-executor`
382
+ // so the (token-costly) LLM agentTurn only fires when there's actual work.
383
+ if ('--quiet' in args) process.exit(tasks.length ? 0 : 1);
337
384
  if (!tasks.length) { console.log('No tasks.'); process.exit(0); }
338
385
  const icon = { submitted: '○', working: '◐', done: '✓', failed: '✗' };
339
386
  for (const t of tasks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallelclaw",
3
- "version": "1.2.1",
3
+ "version": "1.3.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",
package/server.js CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  KNOWN_SOURCES,
45
45
  CONFIG_PATH,
46
46
  } from './lib/config.js';
47
- import { createTask, listTasks, listTaskResults } from './lib/tasks.js';
47
+ import { createTask, listTasks, listTaskResults, triggerSyncPush } from './lib/tasks.js';
48
48
  import {
49
49
  canonicalize as canonicalizeUrl,
50
50
  extractDomain,
@@ -3632,6 +3632,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
3632
3632
  const kind = (typeof args.kind === 'string' && args.kind.trim()) ? args.kind.trim() : 'general';
3633
3633
  const content = typeof args.content === 'string' ? args.content : null;
3634
3634
  const { id } = createTask({ prompt, to, kind, content });
3635
+ triggerSyncPush(); // deliver the delegation to the mesh now, not on the next schedule
3635
3636
  return textResult(
3636
3637
  `✓ Delegated task \`${id}\` → **${to}** (from ${getOrigin()}).\n` +
3637
3638
  `Runs when ${to === 'any' ? 'an always-on agent' : to} is available; the result returns to you ` +