@blockrun/franklin 3.15.30 → 3.15.32

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.
@@ -448,7 +448,18 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
448
448
  persistSessionMeta();
449
449
  };
450
450
  pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
451
- runDataHygiene(); // Trim ~/.blockrun/data + cost_log + remove legacy files
451
+ // Trim ~/.blockrun/data + cost_log + remove legacy files + sweep
452
+ // orphan tool-results dirs. Logs a summary if anything was actually
453
+ // touched — pre-3.15.31 hygiene was completely silent and the only
454
+ // way to verify it was running was poking disk yourself.
455
+ const hygieneReport = runDataHygiene();
456
+ const totalCleaned = hygieneReport.legacyFilesRemoved +
457
+ hygieneReport.dataFilesTrimmed +
458
+ hygieneReport.costLogRowsTrimmed +
459
+ hygieneReport.orphanToolResultsRemoved;
460
+ if (totalCleaned > 0) {
461
+ logger.info(`[franklin] Data hygiene: ${hygieneReport.legacyFilesRemoved} legacy, ${hygieneReport.dataFilesTrimmed} data files, ${hygieneReport.costLogRowsTrimmed} cost_log rows, ${hygieneReport.orphanToolResultsRemoved} orphan tool-results dirs cleaned`);
462
+ }
452
463
  persistSessionMeta();
453
464
  // Flush session meta on SIGINT/SIGTERM so mid-stream Ctrl+C doesn't
454
465
  // leave a stale .meta.json (wrong turnCount/messageCount/cost).
@@ -7,6 +7,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { recordFailure } from '../stats/failures.js';
9
9
  import { BLOCKRUN_DIR } from '../config.js';
10
+ import { logger } from '../logger.js';
10
11
  /** Persist a large tool result to disk and return a preview string. */
11
12
  const PERSIST_THRESHOLD = 50_000;
12
13
  const PREVIEW_SIZE = 2_000;
@@ -205,8 +206,22 @@ export class StreamingExecutor {
205
206
  }
206
207
  }
207
208
  }
209
+ // Track elapsed for slow-tool forensics. Verified 2026-05-04
210
+ // from a real session: a 337.6s Bash error left no trace in
211
+ // franklin-debug.log — the user could see the ✗ in the UI but
212
+ // had no post-hoc way to ask "which Bash took 5+ minutes
213
+ // yesterday and what did it run". 30s threshold is conservative
214
+ // (Read/Glob/Grep finish in <1s; only network or shell work
215
+ // crosses).
216
+ const execStart = Date.now();
208
217
  let result = await handler.execute(invocation.input, progressScope);
209
218
  this.guard?.afterExecute(invocation, result);
219
+ const execElapsed = Date.now() - execStart;
220
+ if (execElapsed >= 30_000) {
221
+ const status = result.isError ? 'error' : 'ok';
222
+ const preview = this.inputPreview(invocation) || '';
223
+ logger.info(`[franklin] Slow tool: ${invocation.name} ${status} after ${(execElapsed / 1000).toFixed(1)}s${preview ? ` — ${preview.slice(0, 80)}` : ''}`);
224
+ }
210
225
  // Persist large results to disk with preview.
211
226
  // Instead of just truncating, save the full result to disk so it can be re-read later.
212
227
  if (result.output.length > PERSIST_THRESHOLD) {
@@ -219,15 +234,22 @@ export class StreamingExecutor {
219
234
  }
220
235
  catch (err) {
221
236
  this.guard?.cancelInvocation(invocation.id);
237
+ const errMsg = err.message;
222
238
  recordFailure({
223
239
  timestamp: Date.now(),
224
240
  model: '', // not available at tool level
225
241
  failureType: 'tool_error',
226
242
  toolName: invocation.name,
227
- errorMessage: err.message,
243
+ errorMessage: errMsg,
228
244
  });
245
+ // Always log thrown tool errors. Pre-3.15.32 these went only to
246
+ // failures.jsonl (which the user rarely opens) and were absent
247
+ // from franklin-debug.log entirely. Now `franklin logs` shows
248
+ // them alongside everything else.
249
+ const preview = this.inputPreview(invocation) || '';
250
+ logger.warn(`[franklin] Tool error: ${invocation.name} threw "${errMsg.slice(0, 120)}"${preview ? ` — ${preview.slice(0, 80)}` : ''}`);
229
251
  return {
230
- output: `Error executing ${invocation.name}: ${err.message}`,
252
+ output: `Error executing ${invocation.name}: ${errMsg}`,
231
253
  isError: true,
232
254
  };
233
255
  }
@@ -21,8 +21,23 @@
21
21
  * unlinkSync). Best-effort: every operation is wrapped so a single failure
22
22
  * never breaks agent boot.
23
23
  */
24
+ /**
25
+ * Summary of what hygiene removed/trimmed in one pass. Returned so the
26
+ * caller (agent loop) can log it — silent hygiene is hard to verify
27
+ * without poking at disk yourself, which is exactly the kind of thing
28
+ * users shouldn't have to do.
29
+ */
30
+ export interface HygieneReport {
31
+ legacyFilesRemoved: number;
32
+ dataFilesTrimmed: number;
33
+ costLogRowsTrimmed: number;
34
+ orphanToolResultsRemoved: number;
35
+ }
24
36
  /**
25
37
  * Top-level entry. Call once at agent session start. Catches its own
26
- * errors so a bad disk never blocks startup.
38
+ * errors so a bad disk never blocks startup. Returns counts so callers
39
+ * can log a one-line summary — verified 2026-05-04 from a real session
40
+ * where hygiene was running silently for hours and there was no way to
41
+ * tell from the log whether anything was being cleaned.
27
42
  */
28
- export declare function runDataHygiene(): void;
43
+ export declare function runDataHygiene(): HygieneReport;
@@ -44,35 +44,46 @@ const LEGACY_FILENAMES = [
44
44
  '0xcode-stats.json',
45
45
  'runcode-debug.log',
46
46
  ];
47
+ const ZERO_REPORT = {
48
+ legacyFilesRemoved: 0,
49
+ dataFilesTrimmed: 0,
50
+ costLogRowsTrimmed: 0,
51
+ orphanToolResultsRemoved: 0,
52
+ };
47
53
  /**
48
54
  * Top-level entry. Call once at agent session start. Catches its own
49
- * errors so a bad disk never blocks startup.
55
+ * errors so a bad disk never blocks startup. Returns counts so callers
56
+ * can log a one-line summary — verified 2026-05-04 from a real session
57
+ * where hygiene was running silently for hours and there was no way to
58
+ * tell from the log whether anything was being cleaned.
50
59
  */
51
60
  export function runDataHygiene() {
61
+ const report = { ...ZERO_REPORT };
52
62
  try {
53
- trimDataDir();
63
+ report.dataFilesTrimmed = trimDataDir();
54
64
  }
55
65
  catch { /* best effort */ }
56
66
  try {
57
- trimCostLog();
67
+ report.costLogRowsTrimmed = trimCostLog();
58
68
  }
59
69
  catch { /* best effort */ }
60
70
  try {
61
- removeLegacyFiles();
71
+ report.legacyFilesRemoved = removeLegacyFiles();
62
72
  }
63
73
  catch { /* best effort */ }
64
74
  try {
65
- sweepOrphanToolResults();
75
+ report.orphanToolResultsRemoved = sweepOrphanToolResults();
66
76
  }
67
77
  catch { /* best effort */ }
78
+ return report;
68
79
  }
69
80
  function trimDataDir() {
70
81
  const dir = path.join(BLOCKRUN_DIR, 'data');
71
82
  if (!fs.existsSync(dir))
72
- return;
83
+ return 0;
73
84
  const entries = fs.readdirSync(dir);
74
85
  if (entries.length === 0)
75
- return;
86
+ return 0;
76
87
  const cutoff = Date.now() - DATA_DIR_MAX_AGE_MS;
77
88
  const stats = [];
78
89
  for (const name of entries) {
@@ -86,11 +97,13 @@ function trimDataDir() {
86
97
  // Best effort — skip unreadable entries.
87
98
  }
88
99
  }
100
+ let removed = 0;
89
101
  // Pass 1: age-based delete.
90
102
  for (const e of stats) {
91
103
  if (e.mtime < cutoff) {
92
104
  try {
93
105
  fs.unlinkSync(path.join(dir, e.name));
106
+ removed++;
94
107
  }
95
108
  catch { /* ok */ }
96
109
  }
@@ -106,35 +119,42 @@ function trimDataDir() {
106
119
  for (let i = 0; i < excess; i++) {
107
120
  try {
108
121
  fs.unlinkSync(path.join(dir, survivors[i].name));
122
+ removed++;
109
123
  }
110
124
  catch { /* ok */ }
111
125
  }
112
126
  }
127
+ return removed;
113
128
  }
114
129
  function trimCostLog() {
115
130
  const file = path.join(BLOCKRUN_DIR, 'cost_log.jsonl');
116
131
  if (!fs.existsSync(file))
117
- return;
132
+ return 0;
118
133
  // Cheap probe — skip the full read+rewrite when the file is small.
119
134
  const stat = fs.statSync(file);
120
135
  if (stat.size < COST_LOG_PROBE_BYTES)
121
- return;
136
+ return 0;
122
137
  const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
123
138
  if (lines.length <= COST_LOG_MAX_ENTRIES)
124
- return;
139
+ return 0;
140
+ const dropped = lines.length - COST_LOG_MAX_ENTRIES;
125
141
  const kept = lines.slice(lines.length - COST_LOG_MAX_ENTRIES);
126
142
  fs.writeFileSync(file, kept.join('\n') + '\n');
143
+ return dropped;
127
144
  }
128
145
  function removeLegacyFiles() {
146
+ let removed = 0;
129
147
  for (const name of LEGACY_FILENAMES) {
130
148
  const p = path.join(BLOCKRUN_DIR, name);
131
149
  if (!fs.existsSync(p))
132
150
  continue;
133
151
  try {
134
152
  fs.unlinkSync(p);
153
+ removed++;
135
154
  }
136
155
  catch { /* ok */ }
137
156
  }
157
+ return removed;
138
158
  }
139
159
  /**
140
160
  * `streaming-executor` writes large tool outputs to
@@ -151,7 +171,7 @@ function sweepOrphanToolResults() {
151
171
  const toolResultsDir = path.join(BLOCKRUN_DIR, 'tool-results');
152
172
  const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions');
153
173
  if (!fs.existsSync(toolResultsDir))
154
- return;
174
+ return 0;
155
175
  const knownSessionIds = new Set();
156
176
  if (fs.existsSync(sessionsDir)) {
157
177
  try {
@@ -165,7 +185,7 @@ function sweepOrphanToolResults() {
165
185
  // Best-effort — if we can't read sessions/, skip the sweep so
166
186
  // we never delete tool-results that might still belong to a
167
187
  // live session.
168
- return;
188
+ return 0;
169
189
  }
170
190
  }
171
191
  let entries;
@@ -173,8 +193,9 @@ function sweepOrphanToolResults() {
173
193
  entries = fs.readdirSync(toolResultsDir);
174
194
  }
175
195
  catch {
176
- return;
196
+ return 0;
177
197
  }
198
+ let removed = 0;
178
199
  for (const name of entries) {
179
200
  if (knownSessionIds.has(name))
180
201
  continue;
@@ -184,9 +205,11 @@ function sweepOrphanToolResults() {
184
205
  if (!stat.isDirectory())
185
206
  continue;
186
207
  fs.rmSync(dir, { recursive: true, force: true });
208
+ removed++;
187
209
  }
188
210
  catch {
189
211
  // Skip — best-effort cleanup.
190
212
  }
191
213
  }
214
+ return removed;
192
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.30",
3
+ "version": "3.15.32",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {