@mclean-capital/neura 3.5.3 → 3.5.4

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.
@@ -8013,6 +8013,7 @@ __export(work_item_queries_exports, {
8013
8013
  deleteWorkItem: () => deleteWorkItem,
8014
8014
  getOpenWorkItems: () => getOpenWorkItems,
8015
8015
  getWorkItem: () => getWorkItem,
8016
+ getWorkItemByWorkerId: () => getWorkItemByWorkerId,
8016
8017
  getWorkItems: () => getWorkItems,
8017
8018
  updateWorkItem: () => updateWorkItem
8018
8019
  });
@@ -8051,6 +8052,13 @@ async function getWorkItem(db, id) {
8051
8052
  const result = await db.query("SELECT * FROM work_items WHERE id = $1", [id]);
8052
8053
  return result.rows.length > 0 ? mapWorkItem(result.rows[0]) : null;
8053
8054
  }
8055
+ async function getWorkItemByWorkerId(db, workerId) {
8056
+ const result = await db.query(
8057
+ "SELECT * FROM work_items WHERE worker_id = $1 LIMIT 1",
8058
+ [workerId]
8059
+ );
8060
+ return result.rows.length > 0 ? mapWorkItem(result.rows[0]) : null;
8061
+ }
8054
8062
  async function createWorkItem(db, title, priority, options) {
8055
8063
  const id = crypto3.randomUUID();
8056
8064
  await db.query(
@@ -76118,7 +76126,6 @@ var ALL_STATUSES = [
76118
76126
  ];
76119
76127
  var COMMENT_TYPES = [
76120
76128
  "progress",
76121
- "heartbeat",
76122
76129
  "clarification_request",
76123
76130
  "approval_request",
76124
76131
  "clarification_response",
@@ -76218,7 +76225,7 @@ var taskToolDefs = [
76218
76225
  {
76219
76226
  type: "function",
76220
76227
  name: "update_task",
76221
- description: "Update a task: field changes, status transitions, and/or comment appends (unified with the worker protocol). Workers use this for report_progress / heartbeat / request_clarification / request_approval / complete_task / fail_task. Orchestrator uses it for user responses (clarification_response / approval_response) and field edits.",
76228
+ description: "Update a task: field changes, status transitions, and/or comment appends (unified with the worker protocol). Workers use this for report_progress / request_clarification / request_approval / complete_task / fail_task. Orchestrator uses it for user responses (clarification_response / approval_response) and field edits.",
76222
76229
  parameters: {
76223
76230
  type: "object",
76224
76231
  properties: {
@@ -76958,6 +76965,8 @@ import {
76958
76965
  SessionManager
76959
76966
  } from "@mariozechner/pi-coding-agent";
76960
76967
  var log19 = new Logger("pi-runtime");
76968
+ var ALIVE_PULSE_THROTTLE_MS = 3e4;
76969
+ var ALIVE_INTERVAL_MS = 6e4;
76961
76970
  var BRIDGE_EVENT_TYPES = /* @__PURE__ */ new Set([
76962
76971
  "agent_start",
76963
76972
  "agent_end",
@@ -77028,6 +77037,15 @@ var PiRuntime = class {
77028
77037
  handleAgentEvent(workerId, event) {
77029
77038
  const worker = this.active.get(workerId);
77030
77039
  if (!worker) return;
77040
+ const now = Date.now();
77041
+ if (now - worker.lastAliveAt >= ALIVE_PULSE_THROTTLE_MS) {
77042
+ worker.lastAliveAt = now;
77043
+ try {
77044
+ worker.callbacks.onAlive?.();
77045
+ } catch (err) {
77046
+ log19.warn("onAlive callback threw", { workerId, err: String(err) });
77047
+ }
77048
+ }
77031
77049
  if (event.type === "agent_start") {
77032
77050
  worker.callbacks.onStatusChange?.("running");
77033
77051
  } else if (event.type === "agent_end") {
@@ -77061,6 +77079,10 @@ var PiRuntime = class {
77061
77079
  worker.callbacks.onStatusChange?.(finalStatus);
77062
77080
  worker.callbacks.onComplete?.(result);
77063
77081
  worker.resolveDone(result);
77082
+ if (worker.aliveInterval) {
77083
+ clearInterval(worker.aliveInterval);
77084
+ worker.aliveInterval = void 0;
77085
+ }
77064
77086
  if (finalStatus !== "idle_partial") {
77065
77087
  this.active.delete(workerId);
77066
77088
  } else {
@@ -77069,6 +77091,28 @@ var PiRuntime = class {
77069
77091
  }
77070
77092
  return result;
77071
77093
  }
77094
+ /**
77095
+ * Start the defense-in-depth interval that refreshes the worker's
77096
+ * lease on a timer, independent of pi's event stream. Pi's bash
77097
+ * tool (and any custom tool that doesn't stream) can go silent for
77098
+ * minutes between `tool_execution_start` and `_end`; without this
77099
+ * pulse the lease would expire and crash-recovery could kill a
77100
+ * perfectly healthy worker that's just waiting on a subprocess.
77101
+ * `unref()` so the timer doesn't keep the process alive on its own.
77102
+ */
77103
+ startAliveInterval(worker) {
77104
+ worker.aliveInterval = setInterval(() => {
77105
+ try {
77106
+ worker.callbacks.onAlive?.();
77107
+ } catch (err) {
77108
+ log19.warn("interval onAlive callback threw", {
77109
+ workerId: worker.workerId,
77110
+ err: String(err)
77111
+ });
77112
+ }
77113
+ }, ALIVE_INTERVAL_MS);
77114
+ worker.aliveInterval.unref?.();
77115
+ }
77072
77116
  /**
77073
77117
  * Authoritative `stopReason` → `WorkerStatus` mapping. Matches the
77074
77118
  * table in docs/phase6-os-core.md exactly:
@@ -77119,9 +77163,11 @@ var PiRuntime = class {
77119
77163
  resolveDone,
77120
77164
  idleWaiters: [],
77121
77165
  pendingPause: false,
77122
- callbacks
77166
+ callbacks,
77167
+ lastAliveAt: 0
77123
77168
  };
77124
77169
  this.active.set(workerId, worker);
77170
+ this.startAliveInterval(worker);
77125
77171
  session.prompt(task.description).then(() => {
77126
77172
  const stopReason = this.extractStopReasonFromSession(session);
77127
77173
  this.finalizeWorker(workerId, stopReason);
@@ -77155,9 +77201,11 @@ var PiRuntime = class {
77155
77201
  resolveDone,
77156
77202
  idleWaiters: [],
77157
77203
  pendingPause: false,
77158
- callbacks
77204
+ callbacks,
77205
+ lastAliveAt: 0
77159
77206
  };
77160
77207
  this.active.set(workerId, worker);
77208
+ this.startAliveInterval(worker);
77161
77209
  session.prompt(resumePrompt).then(() => {
77162
77210
  const stopReason = this.extractStopReasonFromSession(session);
77163
77211
  this.finalizeWorker(workerId, stopReason);
@@ -77457,15 +77505,6 @@ async function listComments(db, opts) {
77457
77505
  );
77458
77506
  return result.rows.map((r2) => mapTaskComment(r2));
77459
77507
  }
77460
- async function pruneHeartbeats(db, taskId, author) {
77461
- const result = await db.query(
77462
- `DELETE FROM task_comments
77463
- WHERE task_id = $1 AND type = 'heartbeat' AND author = $2
77464
- RETURNING id`,
77465
- [taskId, author]
77466
- );
77467
- return result.rows.length;
77468
- }
77469
77508
  async function countOpenRequests(db, taskId) {
77470
77509
  const result = await db.query(
77471
77510
  `SELECT COUNT(*)::TEXT as count FROM task_comments
@@ -77714,6 +77753,7 @@ var WorktreeManager = class {
77714
77753
 
77715
77754
  // src/workers/agent-worker.ts
77716
77755
  var log23 = new Logger("agent-worker");
77756
+ var LEASE_WINDOW_MS = 15 * 6e4;
77717
77757
  var CANONICAL_WORKER_SYSTEM_PROMPT = `You are a Neura worker \u2014 a capable engineering agent executing a task dispatched by the Neura orchestrator. The orchestrator is a voice-first assistant that briefed this task with the user, confirmed intent, and handed it off to you.
77718
77758
 
77719
77759
  Your posture: be decisive. You have full tool access \u2014 Read, Write, Edit, Bash \u2014 scoped to an isolated worktree directory (your cwd). Make progress. Don't ask the user to double-check obvious things. Don't propose a plan and wait for approval when the path is clear.
@@ -77738,12 +77778,13 @@ Actions inside your worktree (creating new files, editing files you created, run
77738
77778
  Communication protocol \u2014 use these tools to report back, not prose:
77739
77779
 
77740
77780
  - \`report_progress(message)\` \u2014 brief status updates. Surfaces to the user as ambient voice. Use sparingly: one update per meaningful step, not one per tool call.
77741
- - \`heartbeat(note?)\` \u2014 signal you're alive on long tasks. Emit at least every 2 minutes when you expect to run longer than that, or the orchestrator will treat you as crashed.
77742
77781
  - \`request_clarification(question, context?, urgency?)\` \u2014 ask the user a blocking question. Returns their answer. Only escalate when you genuinely cannot resolve ambiguity from the task context. Try to answer from context first.
77743
77782
  - \`request_approval(action, rationale?, urgency?)\` \u2014 mandatory before destructive actions (see reversibility rule above).
77744
77783
  - \`complete_task(summary)\` \u2014 mark the task done. Include a short summary of what you did, keyed to the acceptance criteria. The invariant layer will reject this if any clarification or approval is still unresolved.
77745
77784
  - \`fail_task(reason, reason_code)\` \u2014 mark the task failed. Use the right reason_code: \`impossible\` (missing precondition), \`already_done\` (no-op), \`user_aborted\` (user stopped you), \`hard_error\` (exception/timeout).
77746
77785
 
77786
+ You do not need to emit keepalives or heartbeats. The runtime observes your activity through the tool-call stream and refreshes your lease automatically.
77787
+
77747
77788
  Escalation discipline: escalate sparingly. The orchestrator is mediating a voice conversation with a human \u2014 every clarification interrupts it. Only escalate when:
77748
77789
  - You cannot determine which of several paths the user wants (and context doesn't make it obvious).
77749
77790
  - You hit a blocker that requires user authorization (destructive action, external side effect).
@@ -77969,6 +78010,16 @@ var AgentWorker = class {
77969
78010
  callbacks.onStatusChange?.(status);
77970
78011
  },
77971
78012
  onProgress: callbacks.onProgress,
78013
+ onAlive: () => {
78014
+ void this.refreshTaskLease(task.id).catch((err) => {
78015
+ log23.warn("failed to refresh task lease", {
78016
+ workerId,
78017
+ taskId: task.id,
78018
+ err: String(err)
78019
+ });
78020
+ });
78021
+ callbacks.onAlive?.();
78022
+ },
77972
78023
  onComplete: (result) => {
77973
78024
  void this.persistTerminalResult(workerId, result).catch((err) => {
77974
78025
  log23.warn("failed to persist terminal result", {
@@ -78009,6 +78060,10 @@ var AgentWorker = class {
78009
78060
  if (!row.sessionFile) {
78010
78061
  throw new Error(`resume: worker ${workerId} has no session_file`);
78011
78062
  }
78063
+ const linkedTask = await getWorkItemByWorkerId(this.db, workerId);
78064
+ if (linkedTask) {
78065
+ this.workerTaskIds.set(workerId, linkedTask.id);
78066
+ }
78012
78067
  const wrapped = {
78013
78068
  onStatusChange: (status) => {
78014
78069
  void updateWorker(this.db, workerId, { status }).catch((err) => {
@@ -78017,6 +78072,16 @@ var AgentWorker = class {
78017
78072
  callbacks.onStatusChange?.(status);
78018
78073
  },
78019
78074
  onProgress: callbacks.onProgress,
78075
+ onAlive: linkedTask ? () => {
78076
+ void this.refreshTaskLease(linkedTask.id).catch((err) => {
78077
+ log23.warn("failed to refresh task lease on resume", {
78078
+ workerId,
78079
+ taskId: linkedTask.id,
78080
+ err: String(err)
78081
+ });
78082
+ });
78083
+ callbacks.onAlive?.();
78084
+ } : callbacks.onAlive,
78020
78085
  onComplete: (result) => {
78021
78086
  void this.persistTerminalResult(workerId, result).catch((err) => {
78022
78087
  log23.warn("failed to persist terminal result", {
@@ -78126,6 +78191,29 @@ var AgentWorker = class {
78126
78191
  get activeCount() {
78127
78192
  return this.cancellation.activeCount;
78128
78193
  }
78194
+ /**
78195
+ * Refresh a task's lease. Called from the `onAlive` callback the
78196
+ * runtime fires (throttled) on every pi event. Replaces the explicit
78197
+ * `heartbeat` tool the worker used to emit — pi's event stream is a
78198
+ * stronger signal than a self-reported ping and removes a verb from
78199
+ * the worker protocol prompt.
78200
+ *
78201
+ * Terminal tasks are skipped. Pi emits trailing events (agent_end,
78202
+ * final tool_execution_end) after `complete_task`/`fail_task`; without
78203
+ * this guard the lease bump would rewrite `leaseExpiresAt` on a
78204
+ * `done`/`failed`/`cancelled` row and inflate `version`/`updated_at`,
78205
+ * causing spurious optimistic-lock conflicts for the orchestrator's
78206
+ * next edit.
78207
+ */
78208
+ async refreshTaskLease(taskId) {
78209
+ const task = await getWorkItem(this.db, taskId);
78210
+ if (!task) return;
78211
+ if (task.status === "done" || task.status === "failed" || task.status === "cancelled") {
78212
+ return;
78213
+ }
78214
+ const newLease = new Date(Date.now() + LEASE_WINDOW_MS).toISOString();
78215
+ await updateWorkItem(this.db, taskId, { leaseExpiresAt: newLease });
78216
+ }
78129
78217
  /**
78130
78218
  * Persist a terminal result to the workers table. Called by the
78131
78219
  * callbacks wrapper on `onComplete`. Maps the WorkerResult into the
@@ -78250,8 +78338,10 @@ var ClarificationBridge = class {
78250
78338
  if (signal) {
78251
78339
  const onAbort = () => {
78252
78340
  const idx = this.pending.indexOf(pending);
78253
- if (idx >= 0) this.pending.splice(idx, 1);
78254
- reject(new Error("clarification aborted"));
78341
+ if (idx >= 0) {
78342
+ this.pending.splice(idx, 1);
78343
+ reject(new Error("clarification aborted"));
78344
+ }
78255
78345
  };
78256
78346
  if (signal.aborted) {
78257
78347
  onAbort();
@@ -78283,9 +78373,13 @@ var ClarificationBridge = class {
78283
78373
  * clarifications are waiting, the turn is ignored (the user is just
78284
78374
  * talking to Grok normally).
78285
78375
  *
78286
- * Returns true if the turn was consumed by a pending clarification,
78287
- * false otherwise callers can use this to decide whether to also
78288
- * forward the turn to the normal voice session flow.
78376
+ * Returns `true` synchronously when a pending clarification was
78377
+ * found; `false` when nothing was pending. When `true`, the
78378
+ * response persistence (via `onAnswer`) is in flight and the
78379
+ * worker's `askUser` Promise resolves only after the persistence
78380
+ * completes — so `complete_task`'s open-request gate sees the
78381
+ * committed response comment. Callers use the boolean to decide
78382
+ * whether to forward the turn to the normal voice session flow.
78289
78383
  */
78290
78384
  notifyUserTurn(text) {
78291
78385
  const next = this.pending.shift();
@@ -78295,14 +78389,21 @@ var ClarificationBridge = class {
78295
78389
  textPreview: text.slice(0, 80)
78296
78390
  });
78297
78391
  if (next.onAnswer) {
78298
- void Promise.resolve(next.onAnswer(text)).catch((err) => {
78299
- log24.warn("onAnswer persistence hook threw", {
78300
- workerId: next.workerId,
78301
- err: String(err)
78302
- });
78303
- });
78392
+ void (async () => {
78393
+ try {
78394
+ await Promise.resolve(next.onAnswer(text));
78395
+ } catch (err) {
78396
+ log24.warn("onAnswer persistence hook threw", {
78397
+ workerId: next.workerId,
78398
+ err: String(err)
78399
+ });
78400
+ } finally {
78401
+ next.resolve(text);
78402
+ }
78403
+ })();
78404
+ } else {
78405
+ next.resolve(text);
78304
78406
  }
78305
- next.resolve(text);
78306
78407
  return true;
78307
78408
  }
78308
78409
  /** How many clarifications are currently waiting for a user turn. */
@@ -78383,7 +78484,6 @@ var ORCHESTRATOR_ALLOWED_FROM = {
78383
78484
  };
78384
78485
  var WORKER_ALLOWED_COMMENT_TYPES = /* @__PURE__ */ new Set([
78385
78486
  "progress",
78386
- "heartbeat",
78387
78487
  "clarification_request",
78388
78488
  "approval_request",
78389
78489
  "error",
@@ -78504,12 +78604,6 @@ async function applyTaskUpdate(args) {
78504
78604
  urgency: payload.comment.urgency ?? null,
78505
78605
  metadata: payload.comment.metadata ?? null
78506
78606
  });
78507
- if (payload.comment.type !== "heartbeat" && actor.startsWith("worker:")) {
78508
- try {
78509
- await pruneHeartbeats(db, task.id, actor);
78510
- } catch {
78511
- }
78512
- }
78513
78607
  }
78514
78608
  const refreshed = await getWorkItem(db, task.id);
78515
78609
  if (!refreshed) {
@@ -78527,17 +78621,9 @@ async function resolveTask(store, idOrTitle) {
78527
78621
 
78528
78622
  // src/workers/worker-protocol-tools.ts
78529
78623
  var log25 = new Logger("worker-protocol-tools");
78530
- var LEASE_WINDOW_MS = 5 * 6e4;
78531
78624
  var ReportProgressParams = Type.Object({
78532
78625
  message: Type.String({ description: "Short status update the user may hear read aloud." })
78533
78626
  });
78534
- var HeartbeatParams = Type.Object({
78535
- note: Type.Optional(
78536
- Type.String({
78537
- description: "Optional short note. Heartbeats are pruned after your next real comment."
78538
- })
78539
- )
78540
- });
78541
78627
  var RequestClarificationParams2 = Type.Object({
78542
78628
  question: Type.String({ description: "Plain-language question to ask the user." }),
78543
78629
  context: Type.Optional(
@@ -78631,26 +78717,6 @@ function buildWorkerProtocolTools(options) {
78631
78717
  });
78632
78718
  }
78633
78719
  });
78634
- tools.push({
78635
- name: "heartbeat",
78636
- label: "Heartbeat",
78637
- description: "Signal that you're still alive on a long-running task. Refreshes the worker's lease. Emit at least every 2 minutes when you expect to run long so the orchestrator doesn't treat you as crashed.",
78638
- parameters: HeartbeatParams,
78639
- execute: async (_toolCallId, rawParams) => {
78640
- const params = rawParams;
78641
- const newLease = new Date(Date.now() + LEASE_WINDOW_MS).toISOString();
78642
- const result = await taskTools.updateTask(taskId, {
78643
- comment: { type: "heartbeat", content: params.note ?? "still working" },
78644
- fields: { leaseExpiresAt: newLease }
78645
- });
78646
- if (!result) throw new Error(`heartbeat: task ${taskId} not found`);
78647
- return textResult("Heartbeat recorded.", {
78648
- taskId,
78649
- workerId,
78650
- leaseExpiresAt: newLease
78651
- });
78652
- }
78653
- });
78654
78720
  tools.push({
78655
78721
  name: "request_clarification",
78656
78722
  label: "Request Clarification",