parallelclaw 1.2.0 → 1.2.2

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,42 @@
2
2
 
3
3
  Notable changes to parallelclaw (formerly memex-mvp). Older history lives in the git log.
4
4
 
5
+ ## 1.2.2 — durable execution: claim lease + auto-requeue for dead executors
6
+
7
+ Found live: a research task outran Kimi's 240-second cron tick. The executor had
8
+ already written `working` (the claim), but its agentTurn was killed before it
9
+ could write a terminal event → the task was stranded in `working` forever. Fixes:
10
+ - **Claim lease + lazy auto-requeue (#3b).** A `working` claim is leased (default
11
+ 15 min). A stale `working` (claimer died — cron timeout, crash, laptop sleep)
12
+ becomes claimable again and reappears in `task-list --inbox`, so the next
13
+ poller retries it. No task is lost to a dead executor, and no sweeper is needed.
14
+ `claimTask`/`listTasks` gain `now`/`leaseMs` knobs; the Mac executor passes them.
15
+ - **Executor prompt + docs:** read the FULL prompt from the `task-claim` output
16
+ (or `task-claim <id> --json`), never the 80-char `task-list` display line; on a
17
+ too-big task write `failed` with partial progress instead of spinning until the
18
+ run is killed or closing `done` with a placeholder (which would block requeue —
19
+ the exact thing that lost the first research delegation).
20
+
21
+ Tests: +2 (stale working is re-claimable and back in inbox; terminal never
22
+ requeues). Full npm test green.
23
+
24
+ ## 1.2.1 — patch: make the Mac executor's `claude -p` actually complete
25
+
26
+ The Phase 2b headless executor invoked `claude -p` but every run hung until the
27
+ 4-minute timeout. Two fixes, live-verified end-to-end (delegate → Mac executor →
28
+ real `claude -p` result returned in ~7s):
29
+ - **Close the child's stdin.** `claude -p` reads stdin to EOF even with the prompt
30
+ passed as an arg; `execFile` left it open, so the process waited forever. Ending
31
+ stdin lets the run finish and exit.
32
+ - **Strip Claude-session env markers** (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`,
33
+ `CLAUDE_CODE_SSE_PORT`) before spawning, so the headless child is a clean,
34
+ separate session instead of being rejected as "nested inside another Claude
35
+ Code session" (matters when `task-run-once` is invoked from a Claude shell;
36
+ launchd already runs clean).
37
+
38
+ Upgrade required for the installed Mac executor: `npm i -g parallelclaw@1.2.1`
39
+ (the launchd job picks up the fixed code on its next tick — no reinstall needed).
40
+
5
41
  ## 1.2.0 — delegate by talking + the laptop as executor
6
42
 
7
43
  Coordination, now from natural language. Two MCP tools so any MCP client (Claude
package/lib/executor.js CHANGED
@@ -64,8 +64,16 @@ function buildExecPrompt(env) {
64
64
  function claudeRunner(prompt, { timeoutMs = DEFAULT_TIMEOUT_MS, cwd } = {}) {
65
65
  return new Promise((res, rej) => {
66
66
  const bin = process.env.PARALLELCLAW_CLAUDE_BIN || 'claude';
67
- execFile(bin, ['-p', prompt],
68
- { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8', cwd: cwd || DATA },
67
+ // `claude -p` refuses to launch "inside another Claude Code session". The
68
+ // headless executor IS a separate, legitimate session strip the inherited
69
+ // session markers so the child starts clean. launchd already runs without
70
+ // them; this also lets `task-run-once` work when invoked from a Claude shell.
71
+ const env = { ...process.env };
72
+ delete env.CLAUDECODE;
73
+ delete env.CLAUDE_CODE_ENTRYPOINT;
74
+ delete env.CLAUDE_CODE_SSE_PORT;
75
+ const child = execFile(bin, ['-p', prompt],
76
+ { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8', cwd: cwd || DATA, env },
69
77
  (err, stdout, stderr) => {
70
78
  if (err) {
71
79
  if (err.killed) return rej(new Error(`claude -p timed out after ${timeoutMs}ms`));
@@ -73,6 +81,10 @@ function claudeRunner(prompt, { timeoutMs = DEFAULT_TIMEOUT_MS, cwd } = {}) {
73
81
  }
74
82
  res(String(stdout || '').trim());
75
83
  });
84
+ // `claude -p` reads stdin until EOF even when the prompt is passed as an arg.
85
+ // execFile leaves the child's stdin open → it hangs until the timeout. Close
86
+ // it so the run completes and exits (verified: 240s timeout → ~7s with EOF).
87
+ try { child.stdin && child.stdin.end(); } catch (_) {}
76
88
  });
77
89
  }
78
90
 
@@ -92,7 +104,9 @@ export async function runInboxOnce({
92
104
  const me = getOrigin();
93
105
  const exec = runner || claudeRunner;
94
106
  const acted = [];
95
- 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 });
96
110
  inbox = inbox.filter((t) => t.to === me || (includeBroadcast && t.to === 'any'));
97
111
  for (const t of inbox) {
98
112
  if (!allowKinds.includes(t.kind)) {
package/lib/tasks.js CHANGED
@@ -34,6 +34,17 @@ const SOURCE = 'agent-task';
34
34
  const STATUSES = ['submitted', 'working', 'done', 'failed'];
35
35
  const TERMINAL = new Set(['done', 'failed']);
36
36
 
37
+ // A claimed ('working') task whose claimer dies (cron agentTurn timeout, crash,
38
+ // laptop sleep) would otherwise be stranded in 'working' forever. We LEASE the
39
+ // claim: a 'working' event older than LEASE_MS is treated as abandoned and
40
+ // becomes re-claimable by the next poller (lazy requeue — no sweeper, no extra
41
+ // event type). Generous so modest cross-node clock skew can't expire a LIVE
42
+ // claim. Tunable per call via opts.leaseMs.
43
+ const LEASE_MS = 15 * 60 * 1000;
44
+ function isStaleWorking(status, eventTsSec, nowMs, leaseMs) {
45
+ return status === 'working' && (nowMs - (eventTsSec || 0) * 1000) > leaseMs;
46
+ }
47
+
37
48
  // Pick the CURRENT event of a task WITHOUT trusting wall-clock ts — clock skew
38
49
  // across nodes regressed state on the live mesh (a 'done' on a fast clock could
39
50
  // sort before a later 'working'). Order by the monotonic per-task `seq`, then a
@@ -141,14 +152,18 @@ export function updateTask(id, status, { result = null, db = null, now = Date.no
141
152
  * the task wasn't claimable (already taken / terminal) or another node won.
142
153
  * opts: { db?, now? }.
143
154
  */
144
- export function claimTask(id, { db = null, now = Date.now() } = {}) {
155
+ export function claimTask(id, { db = null, now = Date.now(), leaseMs = LEASE_MS } = {}) {
145
156
  const ownDb = !db;
146
157
  db = db || openDb();
147
158
  try {
148
159
  const me = getOrigin();
149
160
  const prev = latestEvent(db, id);
150
161
  if (!prev) throw new Error(`claimTask: no task "${id}"`);
151
- if (prev.envelope.status !== 'submitted') return null; // already working/terminal — not claimable
162
+ const status = prev.envelope.status;
163
+ // Claimable if fresh-submitted OR a STALE 'working' (its claimer died — the
164
+ // lease expired). A fresh 'working' or a terminal state is not claimable.
165
+ const reclaimStale = isStaleWorking(status, prev.ts, now, leaseMs);
166
+ if (status !== 'submitted' && !reclaimStale) return null;
152
167
  const seq = (prev.envelope.seq ?? 0) + 1;
153
168
  const env = { ...prev.envelope, status: 'working', seq, result: prev.envelope.result ?? null };
154
169
  writeEvent(db, { taskId: id, status: 'working', seq, origin: me, now, text: env.prompt || '', envelope: env });
@@ -180,7 +195,7 @@ function latestEvent(db, id) {
180
195
  * inbox — tasks addressed to me AND currently 'submitted' (ready to take)
181
196
  * Returns [{ id, from, to, kind, status, prompt, result, ts }] newest-first.
182
197
  */
183
- export function listTasks({ forOrigin = null, status = null, mine = false, inbox = false, limit = 50, db = null } = {}) {
198
+ export function listTasks({ forOrigin = null, status = null, mine = false, inbox = false, limit = 50, db = null, now = Date.now(), leaseMs = LEASE_MS } = {}) {
184
199
  const ownDb = !db;
185
200
  db = db || openDb();
186
201
  try {
@@ -201,7 +216,9 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
201
216
  }
202
217
  let out = [...latest.values()];
203
218
  // #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');
219
+ // inbox = ready to run: 'submitted', OR a stale 'working' whose claimer died.
220
+ if (inbox) out = out.filter((t) => (t.to === me || t.to === 'any')
221
+ && (t.status === 'submitted' || isStaleWorking(t.status, t.ts, now, leaseMs)));
205
222
  if (mine) out = out.filter((t) => t.from === me);
206
223
  if (forOrigin) out = out.filter((t) => t.to === forOrigin || t.from === forOrigin);
207
224
  if (status) out = out.filter((t) => t.status === status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallelclaw",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",