ai-foreman 1.7.17 → 1.7.18

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/dist/activity.js CHANGED
@@ -188,9 +188,16 @@ export class ActivityReporter {
188
188
  this.lastSemanticKey = semanticKey;
189
189
  return;
190
190
  }
191
+ const semanticKey = semanticStatusKey(body);
192
+ // Append-only outputs cannot replace an earlier line. Emit the first
193
+ // state and meaningful state changes, but do not turn a changing counter
194
+ // or elapsed time into an endless log stream.
195
+ if (semanticKey === this.lastSemanticKey)
196
+ return;
191
197
  if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
192
198
  this.lastHeartbeatAt = now;
193
199
  this.output.write(`[${new Date(now).toISOString()}] rafi working: ${body} (${formatDuration(now - this.commandStartedAt)})\n`);
200
+ this.lastSemanticKey = semanticKey;
194
201
  }
195
202
  }
196
203
  clearLine() {
@@ -277,7 +284,13 @@ function clean(value, maximum) {
277
284
  return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
278
285
  }
279
286
  function semanticStatusKey(body) {
280
- return body.replace(/\p{Number}+(?:[.,]\p{Number}+)*/gu, "#");
287
+ return body
288
+ // Counters, percentages, ticket suffixes, and model version numbers.
289
+ .replace(/\p{Number}+(?:[.,]\p{Number}+)*/gu, "#")
290
+ // Commit IDs, request IDs, and generated hexadecimal identifiers.
291
+ .replace(/\b[\da-f]{7,}\b/giu, "#")
292
+ .replace(/\s+/g, " ")
293
+ .trim();
281
294
  }
282
295
  function resolveTtyMode(requested) {
283
296
  const configured = requested ?? configuredTtyMode(process.env.RAFI_ACTIVITY_RENDER_MODE);
@@ -102,5 +102,10 @@ export declare class ClaudeAdapter implements BuilderAdapter {
102
102
  private failSessionIdentity;
103
103
  private observeSession;
104
104
  private settlePending;
105
+ private touchPendingTurn;
106
+ private beginProviderQuestionWait;
107
+ private endProviderQuestionWait;
108
+ private armPendingTurn;
109
+ private providerIdleTimeoutMs;
105
110
  private streamFailureResult;
106
111
  }
@@ -17,6 +17,7 @@ import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
17
17
  import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
18
18
  import { createProviderSessionRef, validateProviderSessionScope, canonicalSessionPath } from "../sessionIdentity.js";
19
19
  import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
20
+ const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 30 * 60_000;
20
21
  /**
21
22
  * Pure function: build the `options` object passed to `query()`.
22
23
  * Extracted so tests can assert on the shape without making a live SDK call.
@@ -258,18 +259,30 @@ export class ClaudeAdapter {
258
259
  }] }] },
259
260
  stderr: (data) => this.captureStderr(data),
260
261
  canUseTool: async (toolName, input, requestOptions = {}) => {
261
- const decision = await opts.permission({
262
- toolName,
263
- input,
264
- signal: requestOptions.signal,
265
- title: requestOptions.title,
266
- displayName: requestOptions.displayName,
267
- description: requestOptions.description,
268
- decisionReason: requestOptions.decisionReason,
269
- blockedPath: requestOptions.blockedPath,
270
- toolUseID: requestOptions.toolUseID,
271
- });
272
- return permissionDecisionToClaudeResult(decision, requestOptions.toolUseID);
262
+ const isProviderQuestion = toolName === "AskUserQuestion";
263
+ if (isProviderQuestion)
264
+ this.beginProviderQuestionWait();
265
+ try {
266
+ const decision = await opts.permission({
267
+ toolName,
268
+ input,
269
+ signal: requestOptions.signal,
270
+ title: requestOptions.title,
271
+ displayName: requestOptions.displayName,
272
+ description: requestOptions.description,
273
+ decisionReason: requestOptions.decisionReason,
274
+ blockedPath: requestOptions.blockedPath,
275
+ toolUseID: requestOptions.toolUseID,
276
+ });
277
+ if (isProviderQuestion)
278
+ this.endProviderQuestionWait(decision.behavior === "allow");
279
+ return permissionDecisionToClaudeResult(decision, requestOptions.toolUseID);
280
+ }
281
+ catch (error) {
282
+ if (isProviderQuestion)
283
+ this.endProviderQuestionWait(false);
284
+ throw error;
285
+ }
273
286
  },
274
287
  },
275
288
  });
@@ -313,6 +326,7 @@ export class ClaudeAdapter {
313
326
  }
314
327
  }
315
328
  handle(msg) {
329
+ this.touchPendingTurn();
316
330
  if ("session_id" in msg && typeof msg.session_id === "string") {
317
331
  this.observeSession(msg.session_id, "cwd" in msg && typeof msg.cwd === "string" ? msg.cwd : undefined);
318
332
  }
@@ -513,7 +527,8 @@ export class ClaudeAdapter {
513
527
  this.turnSignals = [];
514
528
  this.structuredError = undefined;
515
529
  this.apiErrorStatus = undefined;
516
- this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
530
+ const pending = this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
531
+ this.armPendingTurn(pending);
517
532
  this.inbox.push({
518
533
  type: "user",
519
534
  message: { role: "user", content: text },
@@ -773,6 +788,8 @@ export class ClaudeAdapter {
773
788
  if (!pending)
774
789
  return;
775
790
  this.pending = undefined;
791
+ if (pending.idleTimer)
792
+ clearTimeout(pending.idleTimer);
776
793
  if (!result.turnId) {
777
794
  result.turnId = pending.turnId;
778
795
  result.hostInstruction = pending.instruction;
@@ -785,6 +802,52 @@ export class ClaudeAdapter {
785
802
  this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
786
803
  pending.resolve(result);
787
804
  }
805
+ touchPendingTurn() {
806
+ if (this.pending)
807
+ this.armPendingTurn(this.pending);
808
+ }
809
+ beginProviderQuestionWait() {
810
+ const pending = this.pending;
811
+ if (!pending)
812
+ return;
813
+ pending.providerQuestionWaits = (pending.providerQuestionWaits ?? 0) + 1;
814
+ if (pending.idleTimer)
815
+ clearTimeout(pending.idleTimer);
816
+ pending.idleTimer = undefined;
817
+ }
818
+ endProviderQuestionWait(answered) {
819
+ const pending = this.pending;
820
+ if (!pending)
821
+ return;
822
+ pending.providerQuestionWaits = Math.max(0, (pending.providerQuestionWaits ?? 1) - 1);
823
+ pending.providerQuestionAnswered ||= answered;
824
+ if (pending.providerQuestionWaits === 0)
825
+ this.armPendingTurn(pending);
826
+ }
827
+ armPendingTurn(pending) {
828
+ if (pending.idleTimer)
829
+ clearTimeout(pending.idleTimer);
830
+ if ((pending.providerQuestionWaits ?? 0) > 0)
831
+ return;
832
+ const timeoutMs = this.providerIdleTimeoutMs();
833
+ pending.idleTimer = setTimeout(() => {
834
+ if (this.pending !== pending || this.closed)
835
+ return;
836
+ const context = pending.providerQuestionAnswered ? " after your answer was sent" : "";
837
+ const message = `Claude provider was silent for ${Math.round(timeoutMs / 60_000)} minutes${context}; the turn may have been dispatched and will not be retried automatically`;
838
+ const result = this.streamFailureResult(message, false);
839
+ this.eventQueue.push({ kind: "error", message });
840
+ this.settlePending(result);
841
+ // Do not leave a hidden stream running after the host has declared its
842
+ // result uncertain. close() is intentionally not awaited here.
843
+ void this.close();
844
+ }, timeoutMs);
845
+ pending.idleTimer.unref();
846
+ }
847
+ providerIdleTimeoutMs() {
848
+ const configured = this.opts.providerIdleTimeoutMs;
849
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
850
+ }
788
851
  streamFailureResult(message, sessionUnavailable) {
789
852
  if (sessionUnavailable) {
790
853
  const availability = {
@@ -71,6 +71,8 @@ export declare class CodexAdapter implements BuilderAdapter {
71
71
  private handle;
72
72
  private observationContext;
73
73
  private waitFor;
74
+ private touchWaiters;
75
+ private providerIdleTimeoutMs;
74
76
  private disconnect;
75
77
  private restartForAutoCompaction;
76
78
  private buildSkillsAppendix;
@@ -24,6 +24,8 @@ export function parseCodexLine(raw) {
24
24
  return { events: [{ kind: "error", message: String(raw.error?.message ?? JSON.stringify(raw)) }] };
25
25
  return { events: [] };
26
26
  }
27
+ /** A provider can legitimately take a long time, but not without any event. */
28
+ const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 30 * 60_000;
27
29
  /** Persistent JSON-RPC controller for one live Codex thread. */
28
30
  export class CodexAdapter {
29
31
  opts;
@@ -129,7 +131,7 @@ export class CodexAdapter {
129
131
  await this.ensureThread();
130
132
  this.activeText = [];
131
133
  this.lastTurnTokens = undefined;
132
- const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
134
+ const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId, this.providerIdleTimeoutMs(), true);
133
135
  turnStartDispatched = true;
134
136
  await this.request("turn/start", {
135
137
  threadId: this._sessionId,
@@ -478,6 +480,7 @@ export class CodexAdapter {
478
480
  this.process.stdin.write(`${JSON.stringify(message)}\n`);
479
481
  }
480
482
  handle(message) {
483
+ this.touchWaiters();
481
484
  if (message.id !== undefined) {
482
485
  const pending = this.pending.get(message.id);
483
486
  if (pending) {
@@ -614,28 +617,42 @@ export class CodexAdapter {
614
617
  ticketId: this.opts.ticketId ?? inherited.ticketId, deliveryUnitId: this.opts.deliveryUnitId ?? inherited.deliveryUnitId,
615
618
  parentSpanId: this.activeProviderTurnSpanId ?? inherited.parentSpanId, providerTurnId: this.activeProviderTurnId ?? inherited.providerTurnId };
616
619
  }
617
- waitFor(method, predicate, timeoutMs) {
620
+ waitFor(method, predicate, timeoutMs, resetOnProviderActivity = false) {
618
621
  return new Promise((resolve, reject) => {
619
622
  let timeout;
623
+ const clear = () => { if (timeout)
624
+ clearTimeout(timeout); timeout = undefined; };
620
625
  const waiter = {
621
626
  predicate,
622
- resolve: (params) => { if (timeout)
623
- clearTimeout(timeout); resolve(params); },
624
- reject: (error) => { if (timeout)
625
- clearTimeout(timeout); reject(error); },
627
+ resolve: (params) => { clear(); resolve(params); },
628
+ reject: (error) => { clear(); reject(error); },
626
629
  };
627
630
  const waiters = this.notificationWaiters.get(method) ?? [];
628
631
  waiters.push(waiter);
629
632
  this.notificationWaiters.set(method, waiters);
630
633
  if (timeoutMs !== undefined) {
631
- timeout = setTimeout(() => {
632
- const active = this.notificationWaiters.get(method) ?? [];
633
- this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
634
- reject(new Error(`Timed out waiting for Codex app-server notification ${method}`));
635
- }, timeoutMs);
634
+ const arm = () => {
635
+ timeout = setTimeout(() => {
636
+ const active = this.notificationWaiters.get(method) ?? [];
637
+ this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
638
+ reject(new Error(`Codex provider was silent for ${Math.round(timeoutMs / 60_000)} minutes while waiting for ${method}; the turn may have been dispatched and will not be retried automatically`));
639
+ }, timeoutMs);
640
+ };
641
+ if (resetOnProviderActivity)
642
+ waiter.touch = () => { clear(); arm(); };
643
+ arm();
636
644
  }
637
645
  });
638
646
  }
647
+ touchWaiters() {
648
+ for (const waiters of this.notificationWaiters.values())
649
+ for (const waiter of waiters)
650
+ waiter.touch?.();
651
+ }
652
+ providerIdleTimeoutMs() {
653
+ const configured = this.opts.providerIdleTimeoutMs;
654
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
655
+ }
639
656
  disconnect(error) {
640
657
  const child = this.process;
641
658
  this.process = undefined;
@@ -246,6 +246,13 @@ export interface BuilderAdapterOptions {
246
246
  allowAutoCompactionSetupTurn?: boolean;
247
247
  /** Raw-adapter observability; wrapper adapters must not persist re-emitted events. */
248
248
  observer?: RunObserver;
249
+ /**
250
+ * Maximum time a dispatched provider turn may remain completely silent.
251
+ * This is an idle timeout, not a total turn limit: every provider message
252
+ * resets it. A timeout is intentionally terminal and has unknown dispatch
253
+ * state, so callers must never replay the instruction automatically.
254
+ */
255
+ providerIdleTimeoutMs?: number;
249
256
  }
250
257
  export interface BuilderAdapter {
251
258
  readonly agent: "claude" | "codex";
package/dist/buildRuns.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
@@ -12,6 +12,7 @@ import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } fro
12
12
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
13
13
  import { loadTickets } from "./tickets/ticketLoader.js";
14
14
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
15
+ import { isLiveProcessIdentity, processStartIdentity } from "./processIdentity.js";
15
16
  export const BUILD_RUN_VERSION = 3;
16
17
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
17
18
  export const BUILD_HEARTBEAT_MS = 10_000;
@@ -280,13 +281,7 @@ function workflowLeaseLooksLive(lease, now) {
280
281
  return false;
281
282
  if (lease.host !== hostname())
282
283
  return true;
283
- try {
284
- process.kill(lease.pid, 0);
285
- return lease.processStart === processStartIdentity(lease.pid);
286
- }
287
- catch {
288
- return false;
289
- }
284
+ return isLiveProcessIdentity(lease.pid, lease.processStart);
290
285
  }
291
286
  /**
292
287
  * Current-branch runs created before ticket checkpointing did not retain their
@@ -331,13 +326,7 @@ export function isLeaseActive(run, now = new Date()) {
331
326
  return false;
332
327
  if (run.lease.hostname !== hostname())
333
328
  return true;
334
- try {
335
- process.kill(run.lease.pid, 0);
336
- return run.lease.processStart === processStartIdentity(run.lease.pid);
337
- }
338
- catch {
339
- return false;
340
- }
329
+ return isLiveProcessIdentity(run.lease.pid, run.lease.processStart);
341
330
  }
342
331
  export function buildRecoveryPreview(run) {
343
332
  const completed = Object.keys(run.receipts).sort();
@@ -477,14 +466,6 @@ export function formatBuildRecoveryProjection(projection) {
477
466
  function currentLease(now) {
478
467
  return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
479
468
  }
480
- function processStartIdentity(pid) {
481
- try {
482
- return statSync(`/proc/${pid}`).birthtimeMs.toString();
483
- }
484
- catch {
485
- return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
486
- }
487
- }
488
469
  function validateBuildRun(run) {
489
470
  if (![1, 2, 3].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
490
471
  throw new Error("invalid build run record");
@@ -2,6 +2,7 @@ import { currentActivity } from "../activity.js";
2
2
  /** Print a compact live feed of builder activity. */
3
3
  export async function printEvents(events) {
4
4
  let atLineStart = true;
5
+ const startedTools = new Set();
5
6
  for await (const ev of events) {
6
7
  if (ev.kind === "text") {
7
8
  if (ev.text) {
@@ -17,11 +18,30 @@ export async function printEvents(events) {
17
18
  }
18
19
  }
19
20
  else if (ev.kind === "tool") {
21
+ const key = ev.callId ?? `${ev.name}\u0000${briefInput(ev.input)}`;
22
+ // A tool normally arrives as started, progress, then completed. The
23
+ // activity reporter owns that mutable live state; keeping only its
24
+ // initial permanent line avoids printing the same operation three times.
25
+ if (ev.lifecycle === "progress")
26
+ continue;
27
+ if (ev.lifecycle === "completed" && startedTools.delete(key) && ev.status !== "failed" && (ev.exitCode === undefined || ev.exitCode === 0))
28
+ continue;
29
+ if (ev.lifecycle === "started") {
30
+ startedTools.add(key);
31
+ // The activity display is mutable, so a started tool is shown there
32
+ // and then replaced by its next state instead of becoming a durable
33
+ // line in the feed.
34
+ if (currentActivity())
35
+ continue;
36
+ }
20
37
  if (!atLineStart) {
21
38
  process.stdout.write("\n");
22
39
  atLineStart = true;
23
40
  }
24
- writeLine(` -> ${ev.name} ${briefInput(ev.input)}`);
41
+ const outcome = ev.lifecycle === "completed"
42
+ ? ev.status === "failed" || (ev.exitCode !== undefined && ev.exitCode !== 0) ? " failed" : " completed"
43
+ : "";
44
+ writeLine(` -> ${ev.name} ${briefInput(ev.input)}${outcome}`.trimEnd());
25
45
  }
26
46
  else if (ev.kind === "turn-complete") {
27
47
  if (!atLineStart) {
package/dist/cli/start.js CHANGED
@@ -1857,7 +1857,7 @@ export function buildStartCommand() {
1857
1857
  } },
1858
1858
  }, (line, snapshot) => {
1859
1859
  const activity = currentActivity();
1860
- if (activity && process.stdout.isTTY)
1860
+ if (activity)
1861
1861
  activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1862
1862
  else
1863
1863
  console.log(line);
@@ -2153,7 +2153,7 @@ export function buildStartCommand() {
2153
2153
  } },
2154
2154
  }, (line, snapshot) => {
2155
2155
  const activity = currentActivity();
2156
- if (activity && process.stdout.isTTY)
2156
+ if (activity)
2157
2157
  activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
2158
2158
  else
2159
2159
  console.log(line);
@@ -7,6 +7,7 @@ import { diagnosticDigest, ObservabilityReader, sanitizeDiagnosticValue } from "
7
7
  import { WorkflowReader } from "./workflowReader.js";
8
8
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
9
9
  import { loadTickets } from "./tickets/ticketLoader.js";
10
+ import { isLiveProcessIdentity } from "./processIdentity.js";
10
11
  const WAIT_KINDS = new Set(["user_wait", "dependency_wait", "external_ci_wait"]);
11
12
  const ALLOWED_GIT = new Set(["status", "rev-parse", "rev-list", "diff"]);
12
13
  export function selectDiagnosticRun(projectDir, requestedRunId) {
@@ -317,13 +318,7 @@ function evaluateLease(lease, now) {
317
318
  catch {
318
319
  exists = false;
319
320
  }
320
- let sameStart = false;
321
- try {
322
- sameStart = readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21] === lease.processStart;
323
- }
324
- catch {
325
- sameStart = false;
326
- }
321
+ const sameStart = isLiveProcessIdentity(lease.pid, lease.processStart);
327
322
  const live = age <= 45_000 && exists && sameStart;
328
323
  return { summary: live ? `Lease heartbeat is fresh (${formatDuration(age)} old) and PID/start identity match.` : `Lease health failed: heartbeat age ${formatDuration(age)}, process exists=${exists}, process-start matches=${sameStart}.`, live, failureObserved: !live && (!exists || !sameStart) };
329
324
  }
@@ -5,6 +5,7 @@ import { gzipSync } from "node:zlib";
5
5
  import { hostname, loadavg } from "node:os";
6
6
  import { dirname, join, resolve, sep } from "node:path";
7
7
  import Database from "better-sqlite3";
8
+ import { processStartIdentity } from "./processIdentity.js";
8
9
  export const OBSERVABILITY_DB_FILE = ".rafi/observability.sqlite3";
9
10
  export const OBSERVABILITY_SCHEMA_VERSION = 2;
10
11
  export const DEFAULT_OBSERVABILITY_CONFIG = {
@@ -963,12 +964,6 @@ function stableJson(value) {
963
964
  return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
964
965
  return JSON.stringify(value ?? null);
965
966
  }
966
- function processStartIdentity(pid) { try {
967
- return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
968
- }
969
- catch {
970
- return "unavailable";
971
- } }
972
967
  function databaseBytes(path) { return [path, `${path}-wal`, `${path}-shm`].reduce((sum, item) => sum + (existsSync(item) ? statSync(item).size : 0), 0); }
973
968
  function fileSize(path) { try {
974
969
  return statSync(path).size;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A stable, host-local process incarnation marker. Linux exposes it through
3
+ * procfs; macOS does not, so use `ps lstart` instead. The value is persisted
4
+ * only to detect PID reuse and is never treated as a portable identifier.
5
+ */
6
+ export declare function processStartIdentity(pid?: number): string;
7
+ /** True only when the PID exists and represents the recorded incarnation. */
8
+ export declare function isLiveProcessIdentity(pid: number, expectedStart: string): boolean;
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ /**
4
+ * A stable, host-local process incarnation marker. Linux exposes it through
5
+ * procfs; macOS does not, so use `ps lstart` instead. The value is persisted
6
+ * only to detect PID reuse and is never treated as a portable identifier.
7
+ */
8
+ export function processStartIdentity(pid = process.pid) {
9
+ try {
10
+ const value = readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21];
11
+ // Preserve the historical Linux representation so an upgrade does not
12
+ // invalidate a lease that was created by the prior release.
13
+ if (value)
14
+ return value;
15
+ }
16
+ catch {
17
+ // procfs is intentionally absent on macOS and some constrained hosts.
18
+ }
19
+ try {
20
+ const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
21
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1_000,
22
+ }).trim();
23
+ if (value)
24
+ return `ps:${value}`;
25
+ }
26
+ catch {
27
+ // A caller will treat unavailable identity as unverified, never matching.
28
+ }
29
+ return "unavailable";
30
+ }
31
+ /** True only when the PID exists and represents the recorded incarnation. */
32
+ export function isLiveProcessIdentity(pid, expectedStart) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return expectedStart !== "unavailable" && processStartIdentity(pid) === expectedStart;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
@@ -1,10 +1,10 @@
1
1
  import { hostname } from "node:os";
2
- import { readFileSync } from "node:fs";
3
2
  import { resolve } from "node:path";
4
3
  import { BUILD_LEASE_STALE_MS, readBuildRuns } from "./buildRuns.js";
5
4
  import { collectManagerDiagnostics } from "./diagnostics.js";
6
5
  import { diagnosticDigest, ObservabilityReader } from "./observability.js";
7
6
  import { WorkflowReader } from "./workflowReader.js";
7
+ import { isLiveProcessIdentity } from "./processIdentity.js";
8
8
  export const MANAGER_CATALOG_LIMIT = 30;
9
9
  export const MANAGER_LOOKUP_MAX_ROUNDS = 2;
10
10
  export const MANAGER_LOOKUP_MAX_OPERATIONS = 6;
@@ -261,26 +261,13 @@ function verifiedActiveRunId(lease, now) {
261
261
  return undefined;
262
262
  if (lease.host !== hostname())
263
263
  return undefined;
264
- try {
265
- process.kill(lease.pid, 0);
266
- const start = readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21];
267
- return start === lease.processStart ? lease.runId : undefined;
268
- }
269
- catch {
270
- return undefined;
271
- }
264
+ return isLiveProcessIdentity(lease.pid, lease.processStart) ? lease.runId : undefined;
272
265
  }
273
266
  function verifiedSnapshotLease(run, now) {
274
267
  const lease = run.lease;
275
268
  if (!lease || now.getTime() - Date.parse(lease.heartbeatAt) > BUILD_LEASE_STALE_MS || lease.hostname !== hostname())
276
269
  return false;
277
- try {
278
- process.kill(lease.pid, 0);
279
- return readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21] === lease.processStart;
280
- }
281
- catch {
282
- return false;
283
- }
270
+ return isLiveProcessIdentity(lease.pid, lease.processStart);
284
271
  }
285
272
  function runUpdatedAt(runId, builds, stored, workflows, rollups) { return builds.get(runId)?.updatedAt ?? workflows.get(runId)?.updatedAt ?? stored.get(runId)?.updatedAt ?? rollups.get(runId)?.completedAt ?? rollups.get(runId)?.createdAt ?? ""; }
286
273
  function ticketsFromWorkflow(run) { const value = run?.originalWork; return Array.isArray(value?.tickets) ? value.tickets.filter((item) => typeof item === "string") : []; }
@@ -1,4 +1,5 @@
1
1
  import { isCancel, multiselect, select, text } from "@clack/prompts";
2
+ import { currentActivity, pauseActivityForInput } from "./activity.js";
2
3
  const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
3
4
  const CUSTOM_VALUE = "__rafi_custom_response__";
4
5
  export const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
@@ -24,7 +25,9 @@ export async function handleProviderQuestionTool(req, opts) {
24
25
  const answers = recordFromUnknown(req.input.answers);
25
26
  const annotations = annotationRecordFromUnknown(req.input.annotations);
26
27
  for (const question of questions) {
27
- const answered = await askOneQuestion(question, prompts, req.signal);
28
+ // A native provider question is a local terminal interaction, not provider
29
+ // work. Keep the live activity renderer from overwriting Clack's prompt.
30
+ const answered = await pauseActivityForInput(() => askOneQuestion(question, prompts, req.signal));
28
31
  if (answered.cancelled) {
29
32
  return {
30
33
  behavior: "deny",
@@ -35,6 +38,7 @@ export async function handleProviderQuestionTool(req, opts) {
35
38
  answers[question.question] = answered.answer;
36
39
  if (answered.annotation)
37
40
  annotations[question.question] = answered.annotation;
41
+ currentActivity()?.update("answer sent; waiting for Claude");
38
42
  if (answered.answer.trim()) {
39
43
  opts.onAnsweredQuestion?.({
40
44
  toolName: ASK_USER_QUESTION_TOOL,
@@ -4,6 +4,7 @@ import { hostname } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import Database from "better-sqlite3";
6
6
  import { providerSessionKey } from "./sessionIdentity.js";
7
+ import { isLiveProcessIdentity, processStartIdentity } from "./processIdentity.js";
7
8
  import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
8
9
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
9
10
  /** Read the current project lease without creating or migrating recovery state. */
@@ -1523,24 +1524,12 @@ function sanitizeContinuityValue(value, depth = 0) {
1523
1524
  return Object.fromEntries(Object.entries(value).filter(([key]) => !/^(credential|secret|hidden_reasoning|raw_transcript|api_key|token|password)$/i.test(key)).slice(0, 500).map(([key, entry]) => [key, sanitizeContinuityValue(entry, depth + 1)]));
1524
1525
  return value;
1525
1526
  }
1526
- function processStartIdentity(pid = process.pid) { try {
1527
- return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
1528
- }
1529
- catch {
1530
- return "unavailable";
1531
- } }
1532
1527
  function leaseVerifiedLive(lease, now, staleMs) {
1533
1528
  if (now.getTime() - new Date(lease.heartbeatAt).getTime() > staleMs)
1534
1529
  return false;
1535
1530
  if (lease.host !== hostname())
1536
1531
  return true;
1537
- try {
1538
- process.kill(lease.pid, 0);
1539
- return processStartIdentity(lease.pid) === lease.processStart;
1540
- }
1541
- catch {
1542
- return false;
1543
- }
1532
+ return isLiveProcessIdentity(lease.pid, lease.processStart);
1544
1533
  }
1545
1534
  function ensureRecoveryGitignore(projectDir) {
1546
1535
  const localExclude = join(projectDir, ".git", "info", "exclude");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.17",
3
+ "version": "1.7.18",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",