@blockrun/franklin 3.15.65 → 3.15.67

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.
@@ -562,9 +562,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
562
562
  const totalCleaned = hygieneReport.legacyFilesRemoved +
563
563
  hygieneReport.dataFilesTrimmed +
564
564
  hygieneReport.costLogRowsTrimmed +
565
- hygieneReport.orphanToolResultsRemoved;
565
+ hygieneReport.orphanToolResultsRemoved +
566
+ hygieneReport.brainJunkEntitiesRemoved +
567
+ hygieneReport.oldTasksRemoved;
566
568
  if (totalCleaned > 0) {
567
- logger.info(`[franklin] Data hygiene: ${hygieneReport.legacyFilesRemoved} legacy, ${hygieneReport.dataFilesTrimmed} data files, ${hygieneReport.costLogRowsTrimmed} cost_log rows, ${hygieneReport.orphanToolResultsRemoved} orphan tool-results dirs cleaned`);
569
+ logger.info(`[franklin] Data hygiene: ${hygieneReport.legacyFilesRemoved} legacy, ${hygieneReport.dataFilesTrimmed} data files, ${hygieneReport.costLogRowsTrimmed} cost_log rows, ${hygieneReport.orphanToolResultsRemoved} orphan tool-results dirs, ${hygieneReport.brainJunkEntitiesRemoved} junk brain entities, ${hygieneReport.oldTasksRemoved} expired tasks cleaned`);
568
570
  }
569
571
  persistSessionMeta();
570
572
  // Flush session meta on SIGINT/SIGTERM so mid-stream Ctrl+C doesn't
@@ -22,18 +22,33 @@ function fmtAge(ms) {
22
22
  return `${m}m`;
23
23
  return `${Math.floor(m / 60)}h${m % 60}m`;
24
24
  }
25
+ function reconcileBestEffort() {
26
+ try {
27
+ reconcileLostTasks();
28
+ }
29
+ catch { /* best-effort */ }
30
+ }
25
31
  export function buildTaskCommand() {
26
32
  const cmd = new Command('task').description('Manage long-running detached tasks');
27
33
  cmd
28
34
  .command('list')
29
35
  .description('List recent tasks (newest first)')
30
36
  .action(() => {
31
- reconcileLostTasks();
37
+ reconcileBestEffort();
32
38
  const tasks = listTasks();
33
39
  if (tasks.length === 0) {
34
40
  console.log('No tasks. Start one via the Task agent tool.');
35
41
  return;
36
42
  }
43
+ // Header row matches `franklin content list` shape — verified
44
+ // 2026-05-05 that task list was emitting bare data rows with no
45
+ // column labels, leaving users to guess at the column meaning
46
+ // (`14h19m` could be elapsed-since-start or since-end). The
47
+ // running-vs-terminal age semantics are documented in 3.15.46;
48
+ // the header makes the column itself self-explanatory.
49
+ const idHeader = 'runId';
50
+ const idWidth = Math.max(idHeader.length, ...tasks.map(t => t.runId.length));
51
+ console.log([idHeader.padEnd(idWidth), 'status'.padEnd(10), ' age', 'label'].join(' '));
37
52
  const now = Date.now();
38
53
  for (const t of tasks) {
39
54
  // For a running task, "age" should mean "how long has this been
@@ -48,7 +63,7 @@ export function buildTaskCommand() {
48
63
  ? (t.endedAt ?? t.lastEventAt ?? t.createdAt)
49
64
  : (t.startedAt ?? t.createdAt);
50
65
  const age = fmtAge(now - ageRefMs);
51
- console.log(`${t.runId} ${t.status.padEnd(10)} ${age.padStart(5)} ${t.label}`);
66
+ console.log(`${t.runId.padEnd(idWidth)} ${t.status.padEnd(10)} ${age.padStart(5)} ${t.label}`);
52
67
  }
53
68
  });
54
69
  cmd
@@ -56,6 +71,7 @@ export function buildTaskCommand() {
56
71
  .description('Print log + current status for a task')
57
72
  .option('-f, --follow', 'Poll until task reaches terminal state')
58
73
  .action(async (runId, opts) => {
74
+ reconcileBestEffort();
59
75
  const meta0 = readTaskMeta(runId);
60
76
  if (!meta0) {
61
77
  console.error(`No task: ${runId}`);
@@ -78,6 +94,7 @@ export function buildTaskCommand() {
78
94
  if (opts.follow) {
79
95
  while (true) {
80
96
  await new Promise((r) => setTimeout(r, 1000));
97
+ reconcileBestEffort();
81
98
  printNew();
82
99
  const meta = readTaskMeta(runId);
83
100
  if (meta && isTerminalTaskStatus(meta.status))
@@ -108,6 +125,7 @@ export function buildTaskCommand() {
108
125
  const cap = parseInt(opts.timeout, 10);
109
126
  const t0 = Date.now();
110
127
  while (true) {
128
+ reconcileBestEffort();
111
129
  const meta = readTaskMeta(runId);
112
130
  if (!meta) {
113
131
  console.error(`No task: ${runId}`);
@@ -128,6 +146,7 @@ export function buildTaskCommand() {
128
146
  .command('cancel <runId>')
129
147
  .description('Cancel a running task (SIGTERM to runner)')
130
148
  .action((runId) => {
149
+ reconcileBestEffort();
131
150
  const meta = readTaskMeta(runId);
132
151
  if (!meta) {
133
152
  console.error(`No task: ${runId}`);
@@ -33,6 +33,7 @@ export interface HygieneReport {
33
33
  costLogRowsTrimmed: number;
34
34
  orphanToolResultsRemoved: number;
35
35
  brainJunkEntitiesRemoved: number;
36
+ oldTasksRemoved: number;
36
37
  }
37
38
  /**
38
39
  * Top-level entry. Call once at agent session start. Catches its own
@@ -25,12 +25,22 @@ import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { BLOCKRUN_DIR } from '../config.js';
27
27
  import { pruneJunkBrainEntries } from '../brain/store.js';
28
+ import { getTasksDir, getLegacyTasksDir } from '../tasks/paths.js';
29
+ import { isTerminalTaskStatus } from '../tasks/types.js';
28
30
  // Retention knobs. Tuned conservatively — a power user with 50+ calls/day
29
31
  // for 30 days still fits in DATA_DIR_MAX_FILES, and 5000 cost-log entries
30
32
  // covers months of normal use without truncating the running totals.
31
33
  const DATA_DIR_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
32
34
  const DATA_DIR_MAX_FILES = 2000;
33
35
  const COST_LOG_MAX_ENTRIES = 5000;
36
+ // Task records (meta + events + log per task dir). Verified 2026-05-05:
37
+ // 10 tasks across ~/.franklin/tasks/, oldest "lost" status from 53 hours
38
+ // ago, none ever cleaned up. Each task's log.txt can run 1+ MB for ETL
39
+ // jobs. Without retention, disk fills slowly. 7 days lets a user inspect
40
+ // the previous week's runs but archives anything older. Running tasks
41
+ // are NEVER touched (status check + heartbeat freshness).
42
+ const TASK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
43
+ const TASK_MIN_RETAIN = 5; // always keep the 5 most-recent records regardless of age
34
44
  // Cost log entries are tiny (~60 bytes — ts, endpoint, cost only). 40 bytes
35
45
  // per entry keeps the probe under the real average so a slightly-overlong
36
46
  // file always triggers the rescan rather than silently growing past cap.
@@ -51,6 +61,7 @@ const ZERO_REPORT = {
51
61
  costLogRowsTrimmed: 0,
52
62
  orphanToolResultsRemoved: 0,
53
63
  brainJunkEntitiesRemoved: 0,
64
+ oldTasksRemoved: 0,
54
65
  };
55
66
  /**
56
67
  * Top-level entry. Call once at agent session start. Catches its own
@@ -81,8 +92,74 @@ export function runDataHygiene() {
81
92
  report.brainJunkEntitiesRemoved = pruneJunkBrainEntries().entitiesRemoved;
82
93
  }
83
94
  catch { /* best effort */ }
95
+ try {
96
+ report.oldTasksRemoved = pruneOldTaskRecords();
97
+ }
98
+ catch { /* best effort */ }
84
99
  return report;
85
100
  }
101
+ /**
102
+ * Remove terminal-state task directories older than TASK_MAX_AGE_MS.
103
+ * Scans both the canonical (~/.blockrun/tasks/) and legacy
104
+ * (~/.franklin/tasks/) locations, since 3.15.42 leaves both readable.
105
+ *
106
+ * Safety:
107
+ * - Running / queued tasks are NEVER removed (status check).
108
+ * - Always keep the most-recent TASK_MIN_RETAIN records regardless of
109
+ * age, so users can see recent history after a long pause.
110
+ * - Best-effort: corrupt meta or unreadable dirs are skipped silently.
111
+ */
112
+ function pruneOldTaskRecords() {
113
+ const cutoff = Date.now() - TASK_MAX_AGE_MS;
114
+ let removed = 0;
115
+ const dirs = [getTasksDir()];
116
+ if (process.env.FRANKLIN_HOME === undefined)
117
+ dirs.push(getLegacyTasksDir());
118
+ for (const dir of dirs) {
119
+ let entries;
120
+ try {
121
+ entries = fs.readdirSync(dir);
122
+ }
123
+ catch {
124
+ continue;
125
+ }
126
+ const cands = [];
127
+ for (const name of entries) {
128
+ const taskDir = path.join(dir, name);
129
+ const metaPath = path.join(taskDir, 'meta.json');
130
+ try {
131
+ const stat = fs.statSync(taskDir);
132
+ if (!stat.isDirectory())
133
+ continue;
134
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
135
+ const terminal = typeof meta.status === 'string' && isTerminalTaskStatus(meta.status);
136
+ cands.push({ runId: name, mtime: stat.mtimeMs, terminal, metaPath });
137
+ }
138
+ catch {
139
+ // Unreadable meta or stat — skip silently. We never delete a dir
140
+ // we can't confirm is terminal, to avoid killing a running task
141
+ // whose meta we just couldn't read.
142
+ }
143
+ }
144
+ // Sort newest-first so the slice for retention is at index 0..N-1.
145
+ cands.sort((a, b) => b.mtime - a.mtime);
146
+ const protectedIds = new Set(cands.slice(0, TASK_MIN_RETAIN).map(c => c.runId));
147
+ for (const c of cands) {
148
+ if (protectedIds.has(c.runId))
149
+ continue;
150
+ if (!c.terminal)
151
+ continue; // never touch running/queued
152
+ if (c.mtime >= cutoff)
153
+ continue; // young enough to keep
154
+ try {
155
+ fs.rmSync(path.join(dir, c.runId), { recursive: true, force: true });
156
+ removed++;
157
+ }
158
+ catch { /* ok */ }
159
+ }
160
+ }
161
+ return removed;
162
+ }
86
163
  function trimDataDir() {
87
164
  const dir = path.join(BLOCKRUN_DIR, 'data');
88
165
  if (!fs.existsSync(dir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.65",
3
+ "version": "3.15.67",
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": {