workday-daemon 0.10.0 → 0.15.0

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.
@@ -0,0 +1,156 @@
1
+ import { readdirSync, rmdirSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getDataDir } from './config.js';
4
+ import { readDailyLog, writeDailyLog, deleteDailyLog, trimTrailingPauses, generateSessionId } from './daily-log.js';
5
+ import { ClosedBy } from './types.js';
6
+ import { DEFAULT_ACTIVITY } from './constants.js';
7
+ /**
8
+ * One-time migration: legacy per-session manualAdjustments become one
9
+ * session-born ManualEntry per session (minutes summed, reasons dropped as
10
+ * noise, createdAt = first addedAt). Totals are unchanged and session-born
11
+ * entries fold back into the session aggregate at push time, so the day
12
+ * status is deliberately NOT flipped to Draft — a re-push produces the exact
13
+ * same worklogs. Idempotent: the legacy field is removed from the file.
14
+ */
15
+ function migrateAdjustments(log) {
16
+ let migrated = 0;
17
+ for (const session of log.sessions) {
18
+ const adjustments = session.manualAdjustments;
19
+ if (!Array.isArray(adjustments))
20
+ continue;
21
+ const minutes = adjustments.reduce((sum, a) => sum + (a.minutes ?? 0), 0);
22
+ // Task-less sessions can't become entries (and were never pushable) —
23
+ // leave their legacy field untouched rather than dropping data.
24
+ if (minutes > 0 && session.task) {
25
+ const entry = {
26
+ id: generateSessionId(),
27
+ task: session.task,
28
+ minutes,
29
+ description: '',
30
+ activity: DEFAULT_ACTIVITY,
31
+ createdAt: adjustments[0]?.addedAt ?? session.lastSeenAt,
32
+ sourceSessionId: session.id,
33
+ };
34
+ if (!log.manualEntries)
35
+ log.manualEntries = [];
36
+ log.manualEntries.push(entry);
37
+ migrated += adjustments.length;
38
+ delete session.manualAdjustments;
39
+ }
40
+ else if (minutes === 0) {
41
+ delete session.manualAdjustments; // empty legacy array — plain cleanup
42
+ }
43
+ }
44
+ return migrated;
45
+ }
46
+ /** A day record with zero confirmed facts — safe to delete. */
47
+ export function isEmptyDayLog(log) {
48
+ return log.sessions.length === 0
49
+ && (log.manualEntries ?? []).length === 0
50
+ && !log.pushedAt;
51
+ }
52
+ /** Close orphaned open sessions at their trimmed honest end. */
53
+ function closeOrphans(log) {
54
+ let count = 0;
55
+ for (const session of log.sessions) {
56
+ if (session.closedBy)
57
+ continue;
58
+ const trimmedEnd = trimTrailingPauses(session);
59
+ if (trimmedEnd)
60
+ session.lastSeenAt = trimmedEnd;
61
+ session.closedBy = ClosedBy.DaemonCrash;
62
+ count++;
63
+ }
64
+ return count;
65
+ }
66
+ /** Drop sessions that never reached ACTIVE — invisible to reports. */
67
+ function pruneNeverActivated(log) {
68
+ const before = log.sessions.length;
69
+ log.sessions = log.sessions.filter(s => !!s.activatedAt);
70
+ return before - log.sessions.length;
71
+ }
72
+ /** All stored dates (any content), oldest first. */
73
+ function listAllStoredDates() {
74
+ const dataDir = getDataDir();
75
+ if (!existsSync(dataDir))
76
+ return [];
77
+ const dates = [];
78
+ let monthDirs;
79
+ try {
80
+ monthDirs = readdirSync(dataDir).filter(d => /^\d{4}-\d{2}$/.test(d));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ for (const monthDir of monthDirs) {
86
+ let files;
87
+ try {
88
+ files = readdirSync(join(dataDir, monthDir));
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ for (const file of files) {
94
+ const match = file.match(/^\d{2}-(\d{2})\.json$/);
95
+ if (match)
96
+ dates.push(`${monthDir}-${match[1]}`);
97
+ }
98
+ }
99
+ return dates.sort();
100
+ }
101
+ /** Remove month dirs that ended up empty after file deletion. */
102
+ function removeEmptyMonthDirs() {
103
+ const dataDir = getDataDir();
104
+ if (!existsSync(dataDir))
105
+ return;
106
+ let monthDirs;
107
+ try {
108
+ monthDirs = readdirSync(dataDir).filter(d => /^\d{4}-\d{2}$/.test(d));
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ for (const monthDir of monthDirs) {
114
+ try {
115
+ rmdirSync(join(dataDir, monthDir));
116
+ }
117
+ catch { /* not empty — fine */ }
118
+ }
119
+ }
120
+ /**
121
+ * Run the full startup pass for every stored date before `currentDate`.
122
+ * Corrupted files (unparseable, no backup) are left alone — never deleted
123
+ * blindly. Idempotent: a clean data dir is a no-op.
124
+ */
125
+ export function runStartupJanitor(currentDate) {
126
+ let recovered = 0;
127
+ let pruned = 0;
128
+ let migrated = 0;
129
+ const deletedFiles = [];
130
+ for (const date of listAllStoredDates()) {
131
+ if (date >= currentDate)
132
+ continue;
133
+ const log = readDailyLog(date);
134
+ // Unparseable or structurally alien files are left alone — never delete
135
+ // what we don't understand.
136
+ if (!log || !Array.isArray(log.sessions))
137
+ continue;
138
+ const closedHere = closeOrphans(log);
139
+ const prunedHere = pruneNeverActivated(log);
140
+ const migratedHere = migrateAdjustments(log);
141
+ recovered += closedHere;
142
+ pruned += prunedHere;
143
+ migrated += migratedHere;
144
+ if (isEmptyDayLog(log)) {
145
+ deleteDailyLog(date);
146
+ deletedFiles.push(date);
147
+ }
148
+ else if (closedHere > 0 || prunedHere > 0 || migratedHere > 0) {
149
+ writeDailyLog(log);
150
+ }
151
+ }
152
+ if (deletedFiles.length > 0)
153
+ removeEmptyMonthDirs();
154
+ return { recoveredSessions: recovered, prunedSessions: pruned, deletedFiles, migratedAdjustments: migrated };
155
+ }
156
+ //# sourceMappingURL=janitor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"janitor.js","sourceRoot":"","sources":["../../src/core/janitor.ts"],"names":[],"mappings":"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"}
@@ -67,13 +67,29 @@ export declare class SessionTracker {
67
67
  oldLog: DailyLog;
68
68
  materialized: boolean;
69
69
  };
70
- /** Set dayStartedAt (called by daemon on startup) */
71
- setDayStartedAt(timestamp: string): void;
72
- /** Add manual time adjustment to a session */
73
- addAdjustment(target: string, minutes: number, reason: string): {
70
+ /**
71
+ * Delete a session from today's log — a user decision at review time
72
+ * (junk from a stray touch), never automatic. The record is removed
73
+ * entirely; if the day loses its last confirmed fact the file goes too
74
+ * (storage invariant), unless the day was already pushed — then the file
75
+ * stays as the push marker and the status drops to Draft for re-sync.
76
+ * Deleting an open session is allowed: current activity simply re-births
77
+ * a fresh candidate on the next tick.
78
+ */
79
+ deleteSession(target: string): {
80
+ ok: boolean;
81
+ error?: string;
82
+ deleted?: Session;
83
+ dayFileDeleted?: boolean;
84
+ };
85
+ /**
86
+ * "+ Add time" on a session card: a session-born manual entry. Task comes
87
+ * from the session, activity is Development, no description by design.
88
+ */
89
+ addSessionEntry(target: string, minutes: number): {
74
90
  ok: boolean;
75
91
  error?: string;
76
- sessionId?: string;
92
+ entry?: ManualEntry;
77
93
  };
78
94
  /** Add a manual entry to today's log */
79
95
  addManualEntry(input: {
@@ -95,8 +111,6 @@ export declare class SessionTracker {
95
111
  ok: boolean;
96
112
  error?: string;
97
113
  };
98
- /** Get manual minutes for a session */
99
- getManualMinutes(session: Session): number;
100
114
  /**
101
115
  * Write current daily log to disk (atomic). No-op until the day is
102
116
  * materialized: file exists ⇔ a confirmed fact happened (lazy day).
@@ -142,21 +156,37 @@ export declare class SessionTracker {
142
156
  resumeAllSessions(): void;
143
157
  /** Check if a session is currently paused */
144
158
  hasOpenPause(session: Session): boolean;
159
+ /**
160
+ * Idle auto-close (honest session end): a session sitting in an open
161
+ * IdleTimeout pause longer than session.idleCloseHours closes with a
162
+ * trimmed end (= where the pause chain began). Manual pauses are exempt —
163
+ * a frozen session waits for the user; Superseded transitions into
164
+ * IdleTimeout once the score drains, so it is covered transitively.
165
+ * Runs at the start of each daemon tick, so after a PC-sleep gap the
166
+ * stale session closes before wake-up activity births a new one.
167
+ */
168
+ closeIdleSessions(now: number): void;
169
+ /**
170
+ * Retroactive idle pause after an observation gap (PC sleep / hibernate,
171
+ * suspended process). Every open, unpaused session gets an IdleTimeout
172
+ * pause opened at its own pre-gap lastSeenAt — the last moment work was
173
+ * actually observed — so the gap never counts as work. Sessions already
174
+ * paused keep their pause (it spans the gap naturally; the chain trim
175
+ * yields the honest end either way). Returns the number paused.
176
+ */
177
+ applyGapPauses(): number;
145
178
  /**
146
179
  * True when someone is plausibly mid-work: at least one open, unpaused
147
- * session OR a live candidate (a session mid-birth). Used as the
180
+ * session OR a candidate (a session mid-birth live by construction:
181
+ * a drained candidate evaporates on the same tick). Used as the
148
182
  * quiet-window gate for self-update restarts.
149
183
  */
150
184
  hasActiveWork(): boolean;
151
185
  /** Candidate sessions (in-memory, not yet confirmed facts) in birth order. */
152
186
  getCandidates(): readonly Session[];
153
- /**
154
- * TTL evaporation: a candidate whose last activity is older than
155
- * CANDIDATE_TTL_MINUTES vanishes without logs or traces (A-7).
156
- */
157
- sweepCandidates(now: number): void;
158
187
  private dropCandidate;
159
- private dropAllCandidates;
188
+ /** Evaporate every candidate (rollover, observation gap — state is stale). */
189
+ dropAllCandidates(): void;
160
190
  private findOpenSession;
161
191
  /** Open session first, then candidate — for baseSha/ledger poll context. */
162
192
  private findOpenOrCandidateSession;
@@ -1,10 +1,10 @@
1
1
  import { basename } from 'node:path';
2
- import { SessionState, ClosedBy, SignalType, PauseSource } from './types.js';
2
+ import { SessionState, ClosedBy, DayStatus, SignalType, PauseSource } from './types.js';
3
3
  import { applyLedgerUpdate, countSessionCommits, createEmptyLedger } from './commit-ledger.js';
4
4
  import { isDayMaterialized } from './day-lifecycle.js';
5
- import { CANDIDATE_TTL_MINUTES, MS_PER_MINUTE } from './constants.js';
6
- import { generateSessionId, createEmptyEvidence, createEmptyLog, writeDailyLog, addSignal, addManualAdjustment, addManualEntry, editManualEntry, resolveSessionTarget, computeManualMinutes, getOpenPause, } from './daily-log.js';
5
+ import { generateSessionId, createEmptyEvidence, createEmptyLog, writeDailyLog, deleteDailyLog, addSignal, addManualEntry, editManualEntry, resolveSessionTarget, getOpenPause, trimTrailingPauses, } from './daily-log.js';
7
6
  import { computeWorkingDate, getSensitivityForRepo, resolveSensitivityTicks, writeConfig } from './config.js';
7
+ import { DEFAULT_ACTIVITY } from './constants.js';
8
8
  /**
9
9
  * Manages session lifecycle within a DailyLog.
10
10
  *
@@ -33,21 +33,19 @@ export class SessionTracker {
33
33
  lastEvaluatorResult = null;
34
34
  // In-memory candidate sessions (born from activity, not yet confirmed
35
35
  // facts) keyed by repo name. Promoted to dailyLog.sessions by the
36
- // evaluator (score > 0 && leader); evaporate on TTL / checkout / rollover.
36
+ // evaluator (score > 0 && leader); evaporate when the stamina score
37
+ // drains to zero, on checkout away, or at rollover.
37
38
  candidates = new Map();
38
39
  // Lazy-day gate: a day loaded from disk keeps being written; a fresh draft
39
40
  // materializes only on the first confirmed fact (activation/manual entry).
40
41
  loadedFromDisk;
41
42
  onSessionClosed = null;
42
43
  constructor(config, initialLog) {
43
- const today = computeWorkingDate(Date.now(), config.schedule.end, config.timezone);
44
+ const today = computeWorkingDate(Date.now(), config.boundaryHour, config.timezone);
44
45
  this.config = config;
45
46
  this.dailyLog = initialLog ?? createEmptyLog(today, config);
46
47
  this.loadedFromDisk = initialLog !== undefined;
47
48
  // Normalize old logs that lack new fields
48
- if (this.dailyLog.dayStartedAt === undefined) {
49
- this.dailyLog.dayStartedAt = null;
50
- }
51
49
  if (!this.dailyLog.manualEntries) {
52
50
  this.dailyLog.manualEntries = [];
53
51
  }
@@ -55,8 +53,6 @@ export class SessionTracker {
55
53
  for (const session of this.dailyLog.sessions) {
56
54
  if (!session.pauses)
57
55
  session.pauses = [];
58
- if (!session.manualAdjustments)
59
- session.manualAdjustments = [];
60
56
  if (session.activatedAt === undefined)
61
57
  session.activatedAt = null;
62
58
  if (session.evidence.linesAdded === undefined)
@@ -134,23 +130,17 @@ export class SessionTracker {
134
130
  // 3. Candidate lifecycle — sessions are born from activity, never from
135
131
  // the mere presence of a task branch (checkout alone is not activity).
136
132
  let candidate = this.candidates.get(repoName);
137
- if (candidate && candidate.session.task !== result.task) {
133
+ if (candidate && candidate.task !== result.task) {
138
134
  this.dropCandidate(repoName);
139
135
  candidate = undefined;
140
136
  }
141
137
  if (!candidate) {
142
138
  if (!hasActivity)
143
139
  return; // watching — nothing exists yet
144
- candidate = {
145
- session: this.createSession(repoName, result.task, result.branch, now),
146
- lastActivityAt: Date.now(),
147
- };
140
+ candidate = this.createSession(repoName, result.task, result.branch, now);
148
141
  this.candidates.set(repoName, candidate);
149
142
  }
150
- else if (hasActivity) {
151
- candidate.lastActivityAt = Date.now();
152
- }
153
- this.updateSessionTick(candidate.session, result, now);
143
+ this.updateSessionTick(candidate, result, now);
154
144
  }
155
145
  /**
156
146
  * Close orphaned sessions from a previous daemon crash.
@@ -160,7 +150,9 @@ export class SessionTracker {
160
150
  let count = 0;
161
151
  for (const session of this.dailyLog.sessions) {
162
152
  if (!session.closedBy) {
163
- this.closeOpenPause(session, session.lastSeenAt);
153
+ const trimmedEnd = trimTrailingPauses(session);
154
+ if (trimmedEnd)
155
+ session.lastSeenAt = trimmedEnd;
164
156
  session.closedBy = ClosedBy.DaemonCrash;
165
157
  count++;
166
158
  }
@@ -219,27 +211,62 @@ export class SessionTracker {
219
211
  const completedLog = this.dailyLog;
220
212
  const materialized = isDayMaterialized(completedLog, this.loadedFromDisk);
221
213
  this.dropAllCandidates();
222
- const newDate = computeWorkingDate(Date.now(), this.config.schedule.end, this.config.timezone);
214
+ const newDate = computeWorkingDate(Date.now(), this.config.boundaryHour, this.config.timezone);
223
215
  this.dailyLog = createEmptyLog(newDate, this.config);
224
216
  this.loadedFromDisk = false;
225
217
  this.lastEvaluatorResult = null;
226
218
  return { oldLog: completedLog, materialized };
227
219
  }
228
- /** Set dayStartedAt (called by daemon on startup) */
229
- setDayStartedAt(timestamp) {
230
- if (!this.dailyLog.dayStartedAt) {
231
- this.dailyLog.dayStartedAt = timestamp;
220
+ /**
221
+ * Delete a session from today's log — a user decision at review time
222
+ * (junk from a stray touch), never automatic. The record is removed
223
+ * entirely; if the day loses its last confirmed fact the file goes too
224
+ * (storage invariant), unless the day was already pushed — then the file
225
+ * stays as the push marker and the status drops to Draft for re-sync.
226
+ * Deleting an open session is allowed: current activity simply re-births
227
+ * a fresh candidate on the next tick.
228
+ */
229
+ deleteSession(target) {
230
+ const session = resolveSessionTarget(this.dailyLog, target);
231
+ if (!session) {
232
+ return { ok: false, error: `Session not found: ${target}` };
233
+ }
234
+ this.dailyLog.sessions = this.dailyLog.sessions.filter(s => s !== session);
235
+ this.onSessionClosed?.(session.id);
236
+ if (this.dailyLog.sessions.length === 0
237
+ && (this.dailyLog.manualEntries ?? []).length === 0
238
+ && !this.dailyLog.pushedAt) {
239
+ deleteDailyLog(this.dailyLog.date);
240
+ this.loadedFromDisk = false; // day de-materializes back into a draft
241
+ return { ok: true, deleted: session, dayFileDeleted: true };
242
+ }
243
+ if (this.dailyLog.status !== DayStatus.Draft) {
244
+ this.dailyLog.status = DayStatus.Draft; // pushed day edited → re-sync on next push
232
245
  }
246
+ this.flush();
247
+ return { ok: true, deleted: session, dayFileDeleted: false };
233
248
  }
234
- /** Add manual time adjustment to a session */
235
- addAdjustment(target, minutes, reason) {
249
+ /**
250
+ * "+ Add time" on a session card: a session-born manual entry. Task comes
251
+ * from the session, activity is Development, no description by design.
252
+ */
253
+ addSessionEntry(target, minutes) {
236
254
  const session = resolveSessionTarget(this.dailyLog, target);
237
255
  if (!session) {
238
256
  return { ok: false, error: `Session not found: ${target}` };
239
257
  }
258
+ if (!session.task) {
259
+ return { ok: false, error: 'Session has no task — log the time with `workday log <task> ...`' };
260
+ }
240
261
  try {
241
- addManualAdjustment(this.dailyLog, session.id, minutes, reason, this.config);
242
- return { ok: true, sessionId: session.id };
262
+ const entry = addManualEntry(this.dailyLog, {
263
+ task: session.task,
264
+ minutes,
265
+ description: '',
266
+ activity: DEFAULT_ACTIVITY,
267
+ sourceSessionId: session.id,
268
+ }, this.config);
269
+ return { ok: true, entry };
243
270
  }
244
271
  catch (err) {
245
272
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -265,10 +292,6 @@ export class SessionTracker {
265
292
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
266
293
  }
267
294
  }
268
- /** Get manual minutes for a session */
269
- getManualMinutes(session) {
270
- return computeManualMinutes(session);
271
- }
272
295
  /**
273
296
  * Write current daily log to disk (atomic). No-op until the day is
274
297
  * materialized: file exists ⇔ a confirmed fact happened (lazy day).
@@ -339,8 +362,8 @@ export class SessionTracker {
339
362
  }
340
363
  ticks.push(this.toTickInput(session, resultMap));
341
364
  }
342
- for (const entry of this.candidates.values()) {
343
- ticks.push(this.toTickInput(entry.session, resultMap));
365
+ for (const session of this.candidates.values()) {
366
+ ticks.push(this.toTickInput(session, resultMap));
344
367
  }
345
368
  return ticks;
346
369
  }
@@ -390,18 +413,25 @@ export class SessionTracker {
390
413
  }
391
414
  }
392
415
  }
393
- // Candidate promotion: score > 0 AND leadership — the first active tick
394
- // when the repo leads. The session becomes a confirmed fact: pushed into
395
- // the log and flushed immediately (the moment the day materializes).
416
+ // Candidate lifecycle by evaluator score. Promotion: leadership (implies
417
+ // score > 0) — the first active tick when the repo leads; the session
418
+ // becomes a confirmed fact, pushed into the log and flushed immediately
419
+ // (the moment the day materializes). Fade-out: score drained to zero —
420
+ // the same decay that idle-pauses a session, but an unconfirmed fact has
421
+ // nothing to pause, so it evaporates. Raw score, not isIdleTimeout: an
422
+ // Always-on repo must not breed an immortal candidate.
396
423
  let promoted = false;
397
- for (const [repoName, entry] of [...this.candidates]) {
398
- const sessionScore = result.scores.get(entry.session.id);
424
+ for (const [repoName, session] of [...this.candidates]) {
425
+ const sessionScore = result.scores.get(session.id);
399
426
  if (!sessionScore)
400
427
  continue;
401
- if (sessionScore.score > 0 && result.leaderId === entry.session.id) {
402
- entry.session.state = SessionState.Active;
403
- entry.session.activatedAt = now;
404
- this.dailyLog.sessions.push(entry.session);
428
+ if (sessionScore.score === 0) {
429
+ this.dropCandidate(repoName);
430
+ }
431
+ else if (result.leaderId === session.id) {
432
+ session.state = SessionState.Active;
433
+ session.activatedAt = now;
434
+ this.dailyLog.sessions.push(session);
405
435
  this.candidates.delete(repoName);
406
436
  promoted = true;
407
437
  }
@@ -475,9 +505,55 @@ export class SessionTracker {
475
505
  hasOpenPause(session) {
476
506
  return getOpenPause(session) !== null;
477
507
  }
508
+ /**
509
+ * Idle auto-close (honest session end): a session sitting in an open
510
+ * IdleTimeout pause longer than session.idleCloseHours closes with a
511
+ * trimmed end (= where the pause chain began). Manual pauses are exempt —
512
+ * a frozen session waits for the user; Superseded transitions into
513
+ * IdleTimeout once the score drains, so it is covered transitively.
514
+ * Runs at the start of each daemon tick, so after a PC-sleep gap the
515
+ * stale session closes before wake-up activity births a new one.
516
+ */
517
+ closeIdleSessions(now) {
518
+ const thresholdMs = this.config.session.idleCloseHours * 3_600_000;
519
+ if (thresholdMs <= 0)
520
+ return;
521
+ const nowIso = new Date(now).toISOString();
522
+ for (const session of this.dailyLog.sessions) {
523
+ if (session.closedBy)
524
+ continue;
525
+ const pause = getOpenPause(session);
526
+ if (!pause || pause.source !== PauseSource.IdleTimeout)
527
+ continue;
528
+ if (now - Date.parse(pause.from) >= thresholdMs) {
529
+ this.closeSession(session, ClosedBy.IdleTimeout, nowIso);
530
+ }
531
+ }
532
+ }
533
+ /**
534
+ * Retroactive idle pause after an observation gap (PC sleep / hibernate,
535
+ * suspended process). Every open, unpaused session gets an IdleTimeout
536
+ * pause opened at its own pre-gap lastSeenAt — the last moment work was
537
+ * actually observed — so the gap never counts as work. Sessions already
538
+ * paused keep their pause (it spans the gap naturally; the chain trim
539
+ * yields the honest end either way). Returns the number paused.
540
+ */
541
+ applyGapPauses() {
542
+ let count = 0;
543
+ for (const session of this.dailyLog.sessions) {
544
+ if (session.closedBy)
545
+ continue;
546
+ if (this.hasOpenPause(session))
547
+ continue;
548
+ session.pauses.push({ from: session.lastSeenAt, to: null, source: PauseSource.IdleTimeout });
549
+ count++;
550
+ }
551
+ return count;
552
+ }
478
553
  /**
479
554
  * True when someone is plausibly mid-work: at least one open, unpaused
480
- * session OR a live candidate (a session mid-birth). Used as the
555
+ * session OR a candidate (a session mid-birth live by construction:
556
+ * a drained candidate evaporates on the same tick). Used as the
481
557
  * quiet-window gate for self-update restarts.
482
558
  */
483
559
  hasActiveWork() {
@@ -487,27 +563,16 @@ export class SessionTracker {
487
563
  // ─── Candidates ──────────────────────────────────────────────────────
488
564
  /** Candidate sessions (in-memory, not yet confirmed facts) in birth order. */
489
565
  getCandidates() {
490
- return [...this.candidates.values()].map(entry => entry.session);
491
- }
492
- /**
493
- * TTL evaporation: a candidate whose last activity is older than
494
- * CANDIDATE_TTL_MINUTES vanishes without logs or traces (A-7).
495
- */
496
- sweepCandidates(now) {
497
- const ttlMs = CANDIDATE_TTL_MINUTES * MS_PER_MINUTE;
498
- for (const [repoName, entry] of [...this.candidates]) {
499
- if (now - entry.lastActivityAt > ttlMs) {
500
- this.dropCandidate(repoName);
501
- }
502
- }
566
+ return [...this.candidates.values()];
503
567
  }
504
568
  dropCandidate(repoName) {
505
- const entry = this.candidates.get(repoName);
506
- if (!entry)
569
+ const session = this.candidates.get(repoName);
570
+ if (!session)
507
571
  return;
508
572
  this.candidates.delete(repoName);
509
- this.onSessionClosed?.(entry.session.id);
573
+ this.onSessionClosed?.(session.id);
510
574
  }
575
+ /** Evaporate every candidate (rollover, observation gap — state is stale). */
511
576
  dropAllCandidates() {
512
577
  for (const repoName of [...this.candidates.keys()]) {
513
578
  this.dropCandidate(repoName);
@@ -519,7 +584,7 @@ export class SessionTracker {
519
584
  }
520
585
  /** Open session first, then candidate — for baseSha/ledger poll context. */
521
586
  findOpenOrCandidateSession(repo) {
522
- return this.findOpenSession(repo) ?? this.candidates.get(repo)?.session ?? null;
587
+ return this.findOpenSession(repo) ?? this.candidates.get(repo) ?? null;
523
588
  }
524
589
  createSession(repo, task, branch, now) {
525
590
  // Every session starts from a clean slate — nothing is inherited from
@@ -542,7 +607,6 @@ export class SessionTracker {
542
607
  closedBy: null,
543
608
  evidence: createEmptyEvidence(),
544
609
  pauses: [],
545
- manualAdjustments: [],
546
610
  baseSha: null,
547
611
  mergeBaseSha: null,
548
612
  evidenceBaseline: null,
@@ -553,10 +617,11 @@ export class SessionTracker {
553
617
  closeSession(session, reason, now) {
554
618
  if (session.closedBy)
555
619
  return; // already closed
556
- // Close any open pause before closing the session
557
- this.closeOpenPause(session, now);
620
+ // Honest end: an open trailing pause chain means work actually stopped
621
+ // where the chain began — trim it and end the session there.
622
+ const trimmedEnd = trimTrailingPauses(session);
558
623
  session.closedBy = reason;
559
- session.lastSeenAt = now;
624
+ session.lastSeenAt = trimmedEnd ?? now;
560
625
  // state stays as 'pending' or 'active' — preserved for reporting
561
626
  this.onSessionClosed?.(session.id);
562
627
  }