Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
754 lines (703 loc) · 35.5 KB
/
Copy pathProgram.cs
File metadata and controls
754 lines (703 loc) · 35.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
using System.Diagnostics;
using System.Text.Json;
namespace Tman;
public static partial class Program
{
static string Version
{
get
{
var v = System.Reflection.CustomAttributeExtensions
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(
System.Reflection.Assembly.GetExecutingAssembly())
?.InformationalVersion ?? "dev";
var plus = v.IndexOf('+');
return plus > 0 ? v[..plus] : v;
}
}
public static async Task<int> Main(string[] argv)
{
try
{
// before the store is touched: help must neither create ~/.tman nor need it writable
if (Help.Requested(argv) is { } topic) return PrintHelp(topic);
Store.EnsureDirs();
var cmd = argv[0];
var rest = argv[1..];
switch (cmd)
{
case "run": return await CmdRun(rest, null);
case "list" or "ls": return CmdList(rest);
case "kill": return CmdKill(rest);
case "clean": return CmdClean(rest);
case "status": return CmdStatus(rest);
case "init": return CmdInit(rest);
case "hook": return CmdHook(rest);
case "probe": return CmdProbe(rest);
case "hold": return await CmdHold(rest);
case "--version" or "-v": Console.WriteLine($"tman {Version}"); return 0;
default:
{
var config = Config.Load();
if (config is not null && config.Aliases.TryGetValue(cmd, out var alias))
return await RunAlias(alias, config, rest);
Console.Error.WriteLine($"tman: unknown command or alias '{cmd}'");
Console.WriteLine(Help.Overview());
return Runner.ExitNotFound;
}
}
}
catch (FormatException e)
{
Console.Error.WriteLine($"tman: {e.Message}");
return Runner.ExitNotFound;
}
catch (KnownError e)
{
Console.Error.WriteLine($"tman: {e.Message}");
foreach (var line in e.Instructions) Console.Error.WriteLine($" {line}");
return e.ExitCode;
}
}
/// <summary>
/// Retention for automatic pruning. A broken config must not stop housekeeping, so a config
/// that fails to parse falls back to the built-in window rather than propagating.
/// </summary>
static TimeSpan Retention()
{
try { return Config.EffectiveCaps(null, new Caps(), Config.Load()).Retain ?? Reaper.DefaultRetain; }
catch (FormatException) { return Reaper.DefaultRetain; }
}
static async Task<int> RunAlias(AliasDef alias, TmanConfig config, string[] extraArgs)
{
Reaper.Sweep(Retention());
var args = alias.Args.Concat(extraArgs).ToArray();
var caps = Config.EffectiveCaps(alias, new Caps(), config);
return await GatedRun(alias.Command, args, caps, alias.Name, alias.Name, replace: false,
RunKey.ScopeDir(config), config.Dir, cwd: config.Dir,
queue: alias.NoQueue ? null : Join(alias.Queue ?? config.DefaultQueue, null, alias.Exclusive),
expectedCores: alias.ExpectedCores ?? config.ExpectedCores, expectedMemMb: alias.ExpectedMemMb ?? config.ExpectedMemMb);
}
static async Task<int> CmdRun(string[] argv, string? _)
{
Reaper.Sweep(Retention());
string? name = null, aliasName = null, queueName = null;
bool replace = false, noQueue = false, exclusive = false;
var cliCaps = new Caps();
var cmdArgs = new List<string>();
var i = 0;
var sawDashDash = false;
for (; i < argv.Length; i++)
{
var a = argv[i];
if (!sawDashDash && a == "--") { sawDashDash = true; continue; }
if (!sawDashDash && a.StartsWith("--"))
{
string Next() => i + 1 < argv.Length ? argv[++i] : throw new FormatException($"flag {a} requires a value");
switch (a)
{
case "--name": name = Next(); break;
case "--alias": aliasName = Next(); break;
case "--replace": replace = true; break;
case "--queue": queueName = Next(); break;
case "--no-queue": noQueue = true; break;
case "--exclusive": exclusive = true; break;
case "--max-time" or "--stall" or "--max-mem" or "--max-cpu" or "--max-parallel" or "--limit-cpus"
or "--limit-mem" or "--queue-timeout":
cliCaps = Caps.With(cliCaps, a[2..], Next(), a);
break;
default: throw new FormatException($"unknown flag {a}");
}
continue;
}
cmdArgs.Add(a);
}
if (noQueue && queueName is not null)
throw new FormatException("--queue and --no-queue cannot both be given");
if (noQueue && exclusive)
throw new FormatException("--exclusive needs a queue; --no-queue and --exclusive cannot both be given");
if (aliasName is not null)
{
var config = Config.Load()
?? throw new FormatException("no .tman.kdl found (run 'tman init')");
if (!config.Aliases.TryGetValue(aliasName, out var alias))
throw new FormatException($"alias '{aliasName}' not defined in {config.FilePath}");
var args = alias.Args.Concat(cmdArgs).ToArray();
var caps = Config.EffectiveCaps(alias, cliCaps, config);
// a queue named on the command line overrides the alias's own opt-out; otherwise the
// alias's no-queue stands, alongside the CLI --no-queue
var aliasNoQueue = alias.NoQueue && queueName is null;
// --exclusive is a claim on a queue's shared limit, so it cannot be silently dropped
// onto an alias that opted out of every queue: refuse it and name both settings
if (aliasNoQueue && (alias.Exclusive || exclusive))
throw new FormatException(
$"alias '{aliasName}' sets no-queue, so --exclusive cannot be given: " +
"no-queue and exclusive cannot both be given");
return await GatedRun(
alias.Command, args, caps, name ?? alias.Name, alias.Name, replace,
RunKey.ScopeDir(config), config.Dir, cwd: config.Dir,
queue: noQueue || aliasNoQueue ? null : Join(queueName ?? alias.Queue ?? config.DefaultQueue,
cliCaps.QueueTimeout, alias.Exclusive || exclusive),
expectedCores: alias.ExpectedCores ?? config.ExpectedCores, expectedMemMb: alias.ExpectedMemMb ?? config.ExpectedMemMb);
}
if (cmdArgs.Count == 0) throw new FormatException("run requires a command");
{
var config = Config.Load();
var caps = Config.EffectiveCaps(null, cliCaps, config);
return await GatedRun(
cmdArgs[0], cmdArgs[1..].ToArray(), caps, name, null, replace,
RunKey.ScopeDir(config), config?.Dir,
queue: noQueue ? null : Join(queueName ?? config?.DefaultQueue, cliCaps.QueueTimeout, exclusive),
expectedCores: config?.ExpectedCores, expectedMemMb: config?.ExpectedMemMb);
}
}
/// <summary>
/// What queue a run joins. A name is resolved against the machine config: a machine that declares
/// a "default" queue makes every run whose alias, defaults and `--queue` name no other join it,
/// which is what gives parallel worktrees one shared limit instead of a per-directory
/// `max-parallel`. There are two opt-outs: `--no-queue`, and an alias's `no-queue true`, which the
/// alias and `tman run --alias` doors cannot pass a flag for; both join nothing.
/// </summary>
/// <param name="cliQueueTimeout">
/// `--queue-timeout` overrides the queue's own: a project's `queue-timeout` sizes its bucket's
/// wait, and is no judge of a line shared across projects.
/// </param>
internal static QueueJoin? Join(string? name, TimeSpan? cliQueueTimeout, bool exclusive)
{
// an exclusive run names its own queue, or joins the machine's "default": exclusivity is a
// claim on the shared limit, and a run that holds a queue only if it happens to name one
// would run beside everything on a machine that declared the default for it
var resolved = name ?? Config.MachineDefaultQueue();
if (resolved is null)
{
if (exclusive)
throw new FormatException(
"an exclusive run needs a queue: name one with --queue, or declare the machine's \"default\" queue in " + Config.MachineConfigPath);
return null;
}
var queue = Config.Queue(resolved);
if (cliQueueTimeout is { } timeout) queue = queue with { Timeout = timeout };
return new QueueJoin(queue, exclusive);
}
/// <param name="logDir">
/// The .tman.kdl directory, or null when no config governs the run. Null means no run log: an
/// unconfigured `tman run` happens in whatever directory the caller is standing in, and
/// scattering .tman/ dirs through arbitrary cwds — $HOME included, where it would land beside
/// tman's own store — is not something a supervisor should do uninvited.
/// </param>
/// <param name="cwd">
/// Where the child runs. An alias runs in its .tman.kdl directory, because its args are written
/// relative to that file and mean nothing from a subdirectory; a bare `tman run -- cmd` keeps
/// the caller's directory (null), since the user is standing where they mean to run.
/// </param>
const string ReplacedReason = "replaced by newer run";
/// <summary>A queue a run joins, with the exclusivity it brings.</summary>
public sealed record QueueJoin(NamedQueue Queue, bool Exclusive);
/// <param name="queue">The queue this run joins after its bucket admits it, or null.</param>
internal static async Task<int> GatedRun(
string command, string[] args, Caps caps, string? name, string? alias, bool replace,
string scopeDir, string? logDir = null, string? cwd = null, QueueJoin? queue = null,
double? expectedCores = null, long? expectedMemMb = null)
{
// before any waiting: a run this machine can never confine must not sit in a queue first
if (Confinement.Refusal(caps) is { } refusal) throw new FormatException(refusal);
command = Canon.ResolveCommand(command, cwd ?? Directory.GetCurrentDirectory());
var group = RunKey.For(name, command, scopeDir);
// a supervised process that re-enters tman is one logical run, not a second claim on a slot
var nested = Environment.GetEnvironmentVariable(Runner.ParentIdEnvVar) is not null;
var queueTimeout = caps.QueueTimeout ?? TimeSpan.FromMinutes(5);
FileStream? lockFile = null;
if (name is not null)
{
// holding the name is the claim on it; a name held by a runner that died is free again
// the moment the kernel drops its handle, so nothing here breaks a lock to take it
lockFile = Store.TryAcquireNameLock(group);
if (lockFile is null)
{
var holder = Reaper.FindLiveInGroup(group);
var who = holder is not null ? $" (pid {holder.Pid}, id {holder.Id})" : "";
if (!replace)
{
Console.Error.WriteLine($"tman: run '{name}' already active{who}; use --replace to kill it");
return Runner.ExitKilled;
}
Console.Error.WriteLine($"tman: replacing run '{name}'{who}");
if (holder is not null && !Reaper.KillRun(holder, ReplacedReason))
{
Console.Error.WriteLine($"tman: run '{name}' could not be killed; not replacing it");
return Runner.ExitKilled;
}
// killing the child does not release the name — the runner that holds it does, as
// it winds up. Waiting for that is what replaces the old run instead of joining it.
lockFile = await AwaitNameLock(group, queueTimeout);
if (lockFile is null)
{
Console.Error.WriteLine($"tman: run '{name}' has not released its lock; not replacing it");
return Runner.ExitKilled;
}
}
// the name can still be held by a runner that died leaving its child alive: the kernel
// handed the name back, but the work it stood for is still running
var existing = Reaper.FindLiveInGroup(group);
if (existing is not null)
{
if (!replace)
{
Console.Error.WriteLine($"tman: run '{name}' already active (pid {existing.Pid}, id {existing.Id}); use --replace to kill it");
Store.ReleaseLock(lockFile);
return Runner.ExitKilled;
}
Console.Error.WriteLine($"tman: replacing run '{name}' (pid {existing.Pid})");
if (!Reaper.KillRun(existing, ReplacedReason))
{
Console.Error.WriteLine($"tman: run '{name}' could not be killed; not replacing it");
Store.ReleaseLock(lockFile);
return Runner.ExitKilled;
}
}
}
// Ctrl+C while queued ends the run as cancelled, with a record saying so, instead of killing
// tman where it stands. Held through the run too, so there is no instant between admission
// and the runner's own handler where the signal takes the .NET default.
using var interrupt = new CancellationTokenSource();
ConsoleCancelEventHandler onCancel = (_, e) =>
{
e.Cancel = true;
interrupt.Cancel();
};
Console.CancelKeyPress += onCancel;
var record = Runner.NewRecord(command, args, caps, name, alias, group, cwd);
FileStream? slotFile = null;
try
{
// bucket first, then queue: a run held up in its own project must not sit on a slot of
// a queue other projects are waiting for
if (!nested && caps.MaxParallel is { } maxPar && maxPar > 0)
{
slotFile = await Admission.ClaimBucketSlot(record, maxPar, queueTimeout, interrupt.Token);
if (slotFile is null) return Runner.ExitKilled;
}
if (!nested && queue is not null)
{
var cost = Admission.ExpectedCost(record, queue.Queue, expectedCores, expectedMemMb);
if (!await Admission.ClaimAdmission(record, queue.Queue, cost, queue.Exclusive, expectedCores, expectedMemMb, interrupt.Token))
return Runner.ExitKilled;
}
// the same reasoning as the slot: a nested run is the parent's work, and the parent is
// already capturing it — a second log would carry the same output under another name
using var log = logDir is null || nested ? null : RunLog.Open(logDir, name, alias, command);
return await Runner.Supervise(record, interrupt.Token, log: log);
}
finally
{
Console.CancelKeyPress -= onCancel;
if (slotFile is not null) Store.ReleaseLock(slotFile);
if (lockFile is not null) Store.ReleaseLock(lockFile);
}
}
/// <summary>
/// Waits for a name the caller has just told to go away, up to <paramref name="timeout"/>.
/// Returns null when it is still held then — reporting that is the honest end of a replace that
/// could not happen, where taking the name anyway is the unlink that let two runs share it.
/// </summary>
static async Task<FileStream?> AwaitNameLock(string group, TimeSpan timeout)
{
var waitingSince = Stopwatch.GetTimestamp();
while (true)
{
var claimed = Store.TryAcquireNameLock(group);
if (claimed is not null) return claimed;
if (Stopwatch.GetElapsedTime(waitingSince) >= timeout) return null;
await Task.Delay(50);
}
}
static int CmdList(string[] argv)
{
TakesOnly("list", argv, ["--all"]);
Reaper.Sweep(Retention(), quiet: true);
var all = argv.Contains("--all");
var runs = Store.LoadAll()
.Where(r => all || !r.IsFinished)
.OrderByDescending(r => r.StartedUtc)
.ToList();
if (runs.Count == 0) { Console.WriteLine("no runs"); return 0; }
var now = DateTime.UtcNow;
var records = Store.LoadAll();
Console.WriteLine($"{"ID",-14}{"NAME",-16}{"PROJECT",-26}{"PID",-8}{"STATE",-12}{"AGE",-8}{"PEAKMEM",-9}COMMAND");
foreach (var r in runs)
{
// a nested run is the same work as its parent, marked so the list is not read as two runs
var name = r.IsNested ? "└ " + (r.Name ?? "-") : r.Name ?? "-";
Console.WriteLine(
$"{r.Id,-14}{Canon.Ellipsize(name, 15),-16}{Scope.Short(Scope.RootOf(r)),-26}{PidLabel(r),-8}" +
$"{StateLabel(r, records),-12}{Canon.Duration(now - r.StartedUtc),-8}" +
$"{Canon.Mem(r.PeakMemMb),-9}{Canon.CommandLine(r.Command, r.Args)}");
}
return 0;
}
/// <summary>
/// `tman hold`: reserve an admission in a machine queue for the lifetime of work tman did not
/// start. A client that launches a heavy process tree of its own — an agent runner, a build
/// daemon — runs `tman hold --cores N --mem M --pid P` and tman books that cost in the
/// queue until P exits, so the work is admitted beside everything else on the machine without
/// tman needing to know what it is. Without `--pid`, the hold lasts until it is killed.
/// </summary>
static async Task<int> CmdHold(string[] argv)
{
const string usage = "usage: tman hold --cores N --mem M [--pid <pid>] [--queue <name>]";
double? cores = null;
long? memMb = null;
int? pid = null;
string? queueName = null;
for (var i = 0; i < argv.Length; i++)
{
string Next() => ++i < argv.Length ? argv[i] : throw new FormatException(usage);
switch (argv[i])
{
case "--cores":
cores = double.TryParse(Next(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var c) && c > 0 ? c : null;
if (cores is null) throw new FormatException(usage);
break;
case "--mem":
memMb = Caps.ParseMemMb(Next());
if (memMb is not (> 0)) throw new FormatException(usage);
break;
case "--pid":
pid = int.TryParse(Next(), out var p) && p > 0 ? p : throw new FormatException(usage);
break;
case "--queue": queueName = Next(); break;
default:
throw new FormatException(usage);
}
}
if (cores is null || memMb is null) throw new FormatException(usage);
var join = Join(queueName, null, exclusive: false)
?? throw new FormatException("no machine queue to hold: declare one in the machine config, or pass --queue");
Reaper.Sweep(Retention());
var own = ProcUtil.OwnStart();
var record = new RunRecord
{
Id = Guid.NewGuid().ToString("N")[..12],
Name = "hold",
Command = "hold",
Args = pid is { } target ? [$"--pid={target}"] : [],
RunnerPid = Environment.ProcessId,
RunnerStartUtc = own.Utc,
RunnerStartTicks = own.Ticks,
Pid = Environment.ProcessId,
ChildStartUtc = own.Utc,
ChildStartTicks = own.Ticks,
Cwd = Canon.Dir(Directory.GetCurrentDirectory()),
ProjectRoot = Scope.CurrentProjectRoot(),
AgentSession = Scope.CurrentSession(),
Group = RunKey.For("hold", "hold", RunKey.ScopeDir(Config.Load())),
Caps = new Caps(),
State = RunState.Queued,
Hold = true,
};
using var interrupt = new CancellationTokenSource();
ConsoleCancelEventHandler onCancel = (_, e) => { e.Cancel = true; interrupt.Cancel(); };
Console.CancelKeyPress += onCancel;
try
{
var cost = new Admission.AdmissionCost(cores.Value, memMb.Value);
if (!await Admission.ClaimAdmission(record, join.Queue, cost, join.Exclusive, cores, memMb, interrupt.Token))
return Runner.ExitKilled;
record.State = RunState.Running;
Store.Save(record);
Console.Error.WriteLine(
$"tman: holding {cost.Cores:0.#} cores, {Canon.Mem(cost.MemMb)} in queue '{join.Queue.Name}'" +
(pid is { } heldPid ? $" for pid {heldPid}" : "") + "; Ctrl+C releases");
while (!interrupt.IsCancellationRequested)
{
if (pid is { } held && ProcUtil.Probe(held, null) != ProcessIdentity.Mine)
{
record.State = RunState.Exited;
record.ExitCode = 0;
break;
}
try { await Task.Delay(500, interrupt.Token); }
catch (OperationCanceledException) { break; }
record.HeartbeatUtc = DateTime.UtcNow;
Store.Save(record);
}
if (interrupt.IsCancellationRequested && record.State == RunState.Running)
{
record.State = RunState.Killed;
record.KillReason = "cancelled";
}
record.HeartbeatUtc = DateTime.UtcNow;
Store.Save(record);
return record.State == RunState.Exited ? 0 : Runner.ExitKilled;
}
finally
{
Console.CancelKeyPress -= onCancel;
}
}
/// <summary>
/// `tman probe`: the verdict is the exit code (0 mine, 1 gone, 3 not mine) and is also printed,
/// so a caller can branch on either. It does not sweep — it is a read-only question and must not
/// change what it is asked about.
/// </summary>
static int CmdProbe(string[] argv)
{
const string usage = "usage: tman probe --pid <pid> [--start-ticks <ticks>]";
int? pid = null;
long? ticks = null;
for (var i = 0; i < argv.Length; i++)
{
string Next() => ++i < argv.Length ? argv[i] : throw new FormatException(usage);
switch (argv[i])
{
case "--pid": pid = int.TryParse(Next(), out var p) ? p : throw new FormatException(usage); break;
case "--start-ticks": ticks = long.TryParse(Next(), out var t) ? t : throw new FormatException(usage); break;
default: throw new FormatException(usage);
}
}
if (pid is null) throw new FormatException(usage);
if (ticks is not null && !OperatingSystem.IsLinux())
throw new FormatException("--start-ticks is recorded on Linux only; probe by --pid alone here");
var verdict = ProcUtil.Probe(pid.Value, ticks);
switch (verdict)
{
case ProcessIdentity.Mine: Console.WriteLine("mine"); return 0;
case ProcessIdentity.Gone: Console.WriteLine("gone"); return 1;
default: Console.WriteLine("not-mine"); return 3;
}
}
static int CmdKill(string[] argv)
{
Reaper.Sweep(Retention(), quiet: true);
// a flag that is not understood is refused, not skipped: `kill all --<something>` read as
// `kill all` kills the runs the caller was trying to exclude
var unknown = argv.FirstOrDefault(a => a.StartsWith("--") && a != "--everywhere");
if (unknown is not null) throw new FormatException($"unknown flag {unknown}");
var everywhere = argv.Contains("--everywhere");
var targets = argv.Where(a => a != "--everywhere").ToList();
if (targets.Count == 0) throw new FormatException("kill requires <id|name|all>");
if (everywhere && targets.Any(t => t != "all"))
throw new FormatException("--everywhere only goes with `all`; a run named by id is killed wherever it is");
// checked before anything is killed: an agent has no terminal, so for it this is a refusal
// however the rest of the command reads
if (everywhere && !Scope.HasControllingTerminal())
throw new KnownError(
"`kill all --everywhere` kills every run on this machine, other projects' and sessions' included, and needs a terminal",
[
"This process has no controlling terminal, so it will not do that.",
"To kill only your own runs: tman kill all",
"To kill everything, a person runs this from a terminal: tman kill all --everywhere",
],
Runner.ExitRefused);
var callerRoot = Scope.CurrentProjectRoot();
var callerSession = Scope.CurrentSession();
var killed = 0;
var failed = 0;
var spared = new List<RunRecord>();
foreach (var target in targets)
{
var live = Reaper.LiveRuns();
List<RunRecord> matches;
if (target == "all")
{
matches = live.Where(r => everywhere || Scope.Contains(r, callerRoot, callerSession)).ToList();
spared.AddRange(live.Except(matches));
}
else matches = live.Where(r => r.Matches(target)).ToList();
foreach (var r in matches)
{
// an id is deliberate, so it is honoured; the owner is named so it is never a surprise
var owner = target != "all" && !Scope.Contains(r, callerRoot, callerSession)
? $" — outside this scope, belonging to {Scope.Describe(r)}"
: "";
Console.WriteLine($"tman: killing {r.Name ?? r.Id} (pid {r.Pid}){owner}");
if (Reaper.KillRun(r, "killed via tman kill")) killed++;
else failed++;
}
}
if (spared.Count > 0)
Console.WriteLine(
$"tman: left {spared.Count} live run(s) of other projects or sessions alone " +
"(see `tman list`; kill one by id, or a person can run `tman kill all --everywhere`)");
if (killed == 0 && failed == 0) Console.WriteLine("no matching live runs");
// a run that is still partly alive is not killed, and a script checking the exit must see that
return failed == 0 ? 0 : 1;
}
static int CmdClean(string[] argv)
{
TakesOnly("clean", argv, []);
var retain = Retention();
var (reaped, pruned) = Reaper.Sweep(retain);
Console.WriteLine(
$"tman: reaped {reaped.Count} orphan(s), pruned {pruned} record(s) older than " +
Canon.Duration(retain));
return 0;
}
static int CmdStatus(string[] argv)
{
TakesOnly("status", argv, ["--json"], positionals: 1);
Reaper.Sweep(Retention(), quiet: true);
var target = argv.FirstOrDefault(a => !a.StartsWith("--"));
var live = Reaper.LiveRuns();
if (target is null)
{
var counts = Store.LoadAll().GroupBy(r => r.State).ToDictionary(g => g.Key, g => g.Count());
Console.WriteLine($"live: {live.Count}");
var admitted = live.Where(r => r.AdmittedUtc is not null);
Console.WriteLine(Admission.UnattributedLoad(admitted) is { } outside
? $"unattributed: {outside.Cores:0.#} cores, {Canon.Mem(outside.MemMb)} (host load no admitted run accounts for)"
: "unattributed: not measured on this platform");
foreach (var (state, count) in counts.OrderBy(kv => kv.Key.ToString()))
Console.WriteLine($"{StateLabel(state)}: {count}");
return 0;
}
var r = Reaper.Resolve(target);
if (r is null) { Console.Error.WriteLine($"tman: no run '{target}'"); return Runner.ExitNotFound; }
if (argv.Contains("--json"))
{
Console.WriteLine(RunRecordReport.ToJson(r));
return 0;
}
PrintRunDetail(r);
return 0;
}
static string StateLabel(RunState state) => state.ToString().ToLowerInvariant();
/// <summary>The state, with a named-queue waiter's place in line: `queued #2`.</summary>
static string StateLabel(RunRecord r, IEnumerable<RunRecord> all) =>
Admission.Position(r, all) is { } position ? $"{StateLabel(r.State)} #{position}" : StateLabel(r.State);
/// <summary>A run that never had a child — queued, or unable to start — has no pid to show.</summary>
static string PidLabel(RunRecord r) => r.Pid == 0 ? "-" : r.Pid.ToString();
static void PrintRunDetail(RunRecord r)
{
var now = DateTime.UtcNow;
void Row(string label, string? value)
{
if (!string.IsNullOrEmpty(value)) Console.WriteLine($"{label + ":",-12}{value}");
}
Row("id", r.Id);
Row("name", r.Name);
Row("state", StateLabel(r.State) + (r.ExitCode is { } ec ? $" (exit {ec})" : ""));
Row("command", Canon.CommandLine(r.Command, r.Args, full: true));
Row("cwd", r.Cwd);
Row("bucket", r.Group);
Row("queue", r.Queue is null ? null
: Admission.Position(r, Store.LoadAll()) is { } position ? $"{r.Queue} (position {position})" : r.Queue);
Row("admitted", r.AdmittedUtc is null ? null
: $"{r.AdmittedCores:0.#} cores, {Canon.Mem(r.AdmittedMemMb ?? 0)}" +
(r.Exclusive ? ", exclusive" : "") +
(r.QueuedSeconds is { } qs ? $" (queued {Canon.Duration(TimeSpan.FromSeconds(qs))})" : ""));
Row("parent", r.ParentId);
Row("pid", $"{PidLabel(r)} (runner {r.RunnerPid})");
Row(r.State == RunState.Queued ? "waiting" : "started", $"{r.StartedUtc:u} ({Canon.Duration(now - r.StartedUtc)} ago)");
Row("heartbeat", $"{r.HeartbeatUtc:u} ({Canon.Duration(now - r.HeartbeatUtc)} ago)");
Row("peak mem", Canon.Mem(r.PeakMemMb));
Row("caps", DescribeCaps(r.Caps));
Row("cpus", r.Cpus is null ? null : string.Join(",", r.Cpus));
Row("killed", r.KillReason);
}
internal static string DescribeCaps(Caps c)
{
var parts = new List<string>();
if (c.MaxTime is { } mt) parts.Add($"max-time {Canon.Duration(mt)}");
if (c.Stall is { } st) parts.Add($"stall {Canon.Duration(st)}");
if (c.MaxMemMb is { } mm) parts.Add($"max-mem {Canon.Mem(mm)}");
if (c.MaxCpuPct is { } mc) parts.Add($"max-cpu {mc:0.#}%");
if (c.MaxParallel is { } mp) parts.Add($"max-parallel {mp}");
if (c.LimitCpus is { } lc) parts.Add($"limit-cpus {lc}");
if (c.LimitMemMb is { } lm) parts.Add($"limit-mem {Canon.Mem(lm)}");
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
internal static int CmdInit(string[] argv)
{
TakesOnly("init", argv, ["--shims", "--gitignore"]);
var dir = Directory.GetCurrentDirectory();
var path = Path.Combine(dir, Config.FileName);
var withShims = argv.Contains("--shims");
var withGitignore = argv.Contains("--gitignore");
if (File.Exists(path))
{
Console.WriteLine($"tman: {Config.FileName} already exists");
}
else
{
File.WriteAllText(path, RenderConfig(DetectAliases(dir)));
Console.WriteLine($"tman: wrote {path}");
}
// Shims call into the config on disk, so they are named after its aliases — a kept config's
// aliases are not what detection would scaffold today. A config with none still gets a
// `test` shim, which fails naming the undefined alias until one is written.
var aliases = Config.Load(dir)!.Aliases.Values.Select(a => a.Name).ToList();
var names = aliases.Count > 0 ? aliases : ["test"];
if (withShims)
{
var (written, skipped) = Shim.Generate(dir, names);
foreach (var p in written)
Console.WriteLine($"tman: wrote shim {p}");
foreach (var p in skipped)
Console.WriteLine($"tman: skipped shim {p} (path already exists; use 'tman run' instead)");
}
if (withGitignore && Shim.AppendGitignore(dir, names))
Console.WriteLine("tman: updated .gitignore");
return 0;
}
/// <summary>
/// Claude Code PreToolUse hook: request JSON on stdin, response JSON on stdout. It never
/// returns 2 (the only blocking exit code) and never propagates an exception, because a hook
/// that cannot decide must let the user's command through unchanged.
/// </summary>
static int CmdHook(string[] argv)
{
var evt = argv.FirstOrDefault();
if (evt != "pretooluse")
{
Console.Error.WriteLine($"tman: unknown hook event '{evt ?? ""}' (expected 'pretooluse')");
return Runner.ExitNotFound;
}
try
{
var response = Hook.Render(
Console.In.ReadToEnd(), Environment.GetEnvironmentVariable(Runner.ParentIdEnvVar));
if (response.Length > 0) Console.Out.Write(response);
}
catch (Exception e)
{
Console.Error.WriteLine($"tman: hook error, command left unsupervised: {e.Message}");
}
return 0;
}
internal sealed record DetectedAlias(string Name, string Command, string[] Args);
/// <summary>
/// Prints the help <paramref name="topic"/> names: "" for the overview, a built-in command, or
/// an alias of the nearest .tman.kdl. A name that is none of these is refused like any unknown
/// command, so a typo is not answered with the overview as though it were a request for it.
/// </summary>
static int PrintHelp(string topic)
{
if (topic.Length == 0) { Console.WriteLine(Help.Overview()); return 0; }
if (Help.Find(topic) is { } command) { Console.WriteLine(Help.For(command)); return 0; }
var config = Config.Load();
if (config is not null && config.Aliases.TryGetValue(topic, out var alias))
{
Console.WriteLine(Help.ForAlias(alias, config));
return 0;
}
throw new FormatException($"no command or alias '{topic}' to describe (tman help lists the commands)");
}
/// <summary>
/// Refuses any argument <paramref name="command"/> does not take. A flag that is skipped rather
/// than refused is a request silently ignored — `tman init --help` scaffolded a project, and
/// `tman clean --dry-run` would sweep — so every command reads its arguments whole or not at all.
/// </summary>
static void TakesOnly(string command, string[] argv, string[] flags, int positionals = 0)
{
var seen = 0;
foreach (var a in argv)
{
if (flags.Contains(a)) continue;
if (a.StartsWith('-')) throw new FormatException($"unknown flag {a} for '{command}' (see tman {command} --help)");
if (++seen > positionals)
throw new FormatException($"unexpected argument '{a}' for '{command}' (see tman {command} --help)");
}
}
}