@blockrun/franklin 3.15.65 → 3.15.66

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.
@@ -22,13 +22,19 @@ 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.');
@@ -56,6 +62,7 @@ export function buildTaskCommand() {
56
62
  .description('Print log + current status for a task')
57
63
  .option('-f, --follow', 'Poll until task reaches terminal state')
58
64
  .action(async (runId, opts) => {
65
+ reconcileBestEffort();
59
66
  const meta0 = readTaskMeta(runId);
60
67
  if (!meta0) {
61
68
  console.error(`No task: ${runId}`);
@@ -78,6 +85,7 @@ export function buildTaskCommand() {
78
85
  if (opts.follow) {
79
86
  while (true) {
80
87
  await new Promise((r) => setTimeout(r, 1000));
88
+ reconcileBestEffort();
81
89
  printNew();
82
90
  const meta = readTaskMeta(runId);
83
91
  if (meta && isTerminalTaskStatus(meta.status))
@@ -108,6 +116,7 @@ export function buildTaskCommand() {
108
116
  const cap = parseInt(opts.timeout, 10);
109
117
  const t0 = Date.now();
110
118
  while (true) {
119
+ reconcileBestEffort();
111
120
  const meta = readTaskMeta(runId);
112
121
  if (!meta) {
113
122
  console.error(`No task: ${runId}`);
@@ -128,6 +137,7 @@ export function buildTaskCommand() {
128
137
  .command('cancel <runId>')
129
138
  .description('Cancel a running task (SIGTERM to runner)')
130
139
  .action((runId) => {
140
+ reconcileBestEffort();
131
141
  const meta = readTaskMeta(runId);
132
142
  if (!meta) {
133
143
  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.66",
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": {