@@ -13,6 +13,8 @@ import { hostname } from "node:os";
1313import { join , relative } from "node:path" ;
1414import process from "node:process" ;
1515
16+ import { isRecord } from "../util/is-record" ;
17+
1618/**
1719 * The transient verification space one `check` (or `rule restore`) works in:
1820 * `.taskless/.run/<runId>/`.
@@ -43,9 +45,12 @@ import process from "node:process";
4345 *
4446 * **Age is only a backstop.** Any owned directory whose run started more than
4547 * {@link ABANDONED_AFTER_MS} ago is swept whatever its owner. That covers what
46- * liveness cannot: a dead run's pid recycled by an unrelated process, a
47- * foreign host that never came back, and a preserved directory nobody went
48- * back to. No `check` runs for a day. A directory
48+ * liveness cannot: a dead run's pid recycled by an unrelated process, and a
49+ * foreign host that never came back. No `check` runs for a day. Every time is
50+ * unix milliseconds recorded by the run itself, never a filesystem timestamp,
51+ * which copies, checkouts, and backups rewrite. A preserved directory is kept
52+ * by its marker's own `keepUntil`, which `--preserve-logs` sets to the same day
53+ * and a user may raise. A directory
4954 * whose name is not a run id predates run ids (0.11's `runtime-rules/`, the
5055 * first 0.12 `snapshot/`) and is swept too. A run-id directory with no `owner`
5156 * is swept only after a grace period, because that is also what a run looks
@@ -84,12 +89,24 @@ const SIGNAL_FLUSH_MS = 2000;
8489interface Owner {
8590 pid : number ;
8691 hostname : string ;
87- startedAt : string ;
92+ /** Unix milliseconds. A number, not a date string, so reading it cannot fail softly. */
93+ startedAt : number ;
8894}
8995
9096/** The marker `--preserve-logs` writes beside `owner`. */
9197const PRESERVE_MARKER = "preserve" ;
9298
99+ /**
100+ * What the marker holds. `keepUntil` is the directory's own deadline, in unix
101+ * milliseconds, so a kept directory's lifetime depends on nothing but this
102+ * file: not on `owner`, and never on a filesystem timestamp. Raising it keeps
103+ * the directory longer; deleting the file releases it.
104+ */
105+ interface PreserveMarker {
106+ keepUntil : number ;
107+ note : string ;
108+ }
109+
93110/** One append-only log file in a run directory. */
94111export class RunLog {
95112 private pending : Promise < void > = Promise . resolve ( ) ;
@@ -158,32 +175,47 @@ function isAlive(pid: number): boolean {
158175 }
159176}
160177
161- async function readOwner ( directory : string ) : Promise < Owner | undefined > {
178+ async function readJson ( path : string ) : Promise < Record < string , unknown > > {
162179 try {
163- const parsed = JSON . parse (
164- await readFile ( join ( directory , "owner" ) , "utf8" )
165- ) as Partial < Owner > ;
166- return typeof parsed . pid === "number" && typeof parsed . hostname === "string"
167- ? ( parsed as Owner )
168- : undefined ;
180+ const parsed : unknown = JSON . parse ( await readFile ( path , "utf8" ) ) ;
181+ return isRecord ( parsed ) ? parsed : { } ;
169182 } catch {
183+ return { } ;
184+ }
185+ }
186+
187+ /**
188+ * The `owner` record, or `undefined` when there is none or it is not a record
189+ * yet. A run writes `owner` just after creating its directory, so a sweep can
190+ * read it half-written; that must look ownerless (and get the grace), never
191+ * old. A well-formed record whose `startedAt` is not a finite number reads as
192+ * starting at the epoch, past the day-old backstop: an age that cannot be
193+ * known is treated as old, never as new.
194+ */
195+ async function readOwner ( directory : string ) : Promise < Owner | undefined > {
196+ const parsed = await readJson ( join ( directory , "owner" ) ) ;
197+ if ( typeof parsed . pid !== "number" || typeof parsed . hostname !== "string" ) {
170198 return undefined ;
171199 }
200+ return {
201+ pid : parsed . pid ,
202+ hostname : parsed . hostname ,
203+ startedAt : Number . isFinite ( parsed . startedAt )
204+ ? ( parsed . startedAt as number )
205+ : 0 ,
206+ } ;
172207}
173208
174209/**
175- * Whether the run that owns `directory` started more than `ms` ago, from the
176- * owner's `startedAt`, or the directory's own mtime when that does not parse.
210+ * Whether the directory's `preserve` marker still holds it. A marker that is
211+ * missing, unreadable, or has no numeric `keepUntil` holds nothing, and the
212+ * directory falls back to the ordinary liveness rules.
177213 */
178- async function ownerOlderThan (
179- owner : Owner ,
180- directory : string ,
181- ms : number
182- ) : Promise < boolean > {
183- const started = Date . parse ( owner . startedAt ) ;
184- return Number . isNaN ( started )
185- ? olderThan ( directory , ms )
186- : Date . now ( ) - started > ms ;
214+ async function isPreserved ( directory : string ) : Promise < boolean > {
215+ const path = join ( directory , PRESERVE_MARKER ) ;
216+ if ( ! ( await exists ( path ) ) ) return false ;
217+ const { keepUntil } = await readJson ( path ) ;
218+ return Number . isFinite ( keepUntil ) && Date . now ( ) < ( keepUntil as number ) ;
187219}
188220
189221async function exists ( path : string ) : Promise < boolean > {
@@ -221,15 +253,11 @@ export async function sweepAbandonedRuns(cwd: string): Promise<string[]> {
221253 for ( const entry of entries ) {
222254 if ( ! entry . isDirectory ( ) ) continue ;
223255 const directory = join ( root , entry . name ) ;
256+ if ( await isPreserved ( directory ) ) continue ;
224257 const owner = await readOwner ( directory ) ;
225258 if ( owner !== undefined ) {
226- const expired = await ownerOlderThan (
227- owner ,
228- directory ,
229- ABANDONED_AFTER_MS
230- ) ;
259+ const expired = Date . now ( ) - owner . startedAt > ABANDONED_AFTER_MS ;
231260 if ( ! expired ) {
232- if ( await exists ( join ( directory , PRESERVE_MARKER ) ) ) continue ;
233261 if ( owner . hostname !== hostname ( ) ) continue ;
234262 if ( isAlive ( owner . pid ) ) continue ;
235263 }
@@ -275,13 +303,17 @@ export async function openRun(
275303 const owner : Owner = {
276304 pid : process . pid ,
277305 hostname : hostname ( ) ,
278- startedAt : now . toISOString ( ) ,
306+ startedAt : now . getTime ( ) ,
279307 } ;
280308 if ( preserve ) {
281309 // Before `owner`, so no sweep can see this run owned but unmarked.
310+ const marker : PreserveMarker = {
311+ keepUntil : now . getTime ( ) + ABANDONED_AFTER_MS ,
312+ note : "Kept by --preserve-logs until keepUntil (unix ms). Raise it to keep this directory longer; delete this file to let the next run remove it." ,
313+ } ;
282314 await writeFile (
283315 join ( path , PRESERVE_MARKER ) ,
284- "Kept by --preserve-logs. A run sweeps this directory once it is a day old; delete this file to let the next run sweep it sooner.\n" ,
316+ ` ${ JSON . stringify ( marker , undefined , 2 ) } \n` ,
285317 "utf8"
286318 ) ;
287319 }
@@ -293,6 +325,14 @@ export async function openRun(
293325 vale : new RunLog ( join ( path , "vale.log" ) ) ,
294326 runtime : new RunLog ( join ( path , "runtime.log" ) ) ,
295327 } ;
328+ // Every log exists from the start, so a kept directory always holds all
329+ // four, and an empty one says that engine had nothing to do rather than
330+ // leaving the reader to wonder whether it was ever logged.
331+ await Promise . all (
332+ [ logs . engine , logs . sg , logs . vale , logs . runtime ] . map ( async ( log ) =>
333+ writeFile ( log . path , "" , { flag : "a" } )
334+ )
335+ ) ;
296336 logs . engine . write ( `run ${ id } started (pid ${ String ( process . pid ) } )` ) ;
297337 if ( swept . length > 0 ) {
298338 logs . engine . write ( `swept abandoned run directories: ${ swept . join ( ", " ) } ` ) ;
0 commit comments