@zgeoff/atc 3.1.0 → 3.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -80,13 +80,26 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
80
80
  // until revived by hand, so no terminal is adopted for them. An entry
81
81
  // whose session is still listed, dead, revives that session in place
82
82
  // rather than listing a second session under the same id.
83
- const registered = entries.map((entry) => {
84
- const listed = mgr.sessions.find((s) => s.id === entry.sessionID);
83
+ // A row that cannot be registered is logged and skipped, so one bad row
84
+ // never keeps the rest of the fleet down.
85
+ const registered: { session: Session; revive: boolean }[] = [];
85
86
 
86
- return listed === undefined
87
- ? { session: mgr.restore(entry), revive: false }
88
- : { session: listed, revive: true };
89
- });
87
+ for (const entry of entries) {
88
+ try {
89
+ const listed = mgr.sessions.find((s) => s.id === entry.sessionID);
90
+
91
+ const row =
92
+ listed === undefined
93
+ ? { session: mgr.restore(entry), revive: false }
94
+ : { session: listed, revive: true };
95
+
96
+ registered.push(row);
97
+ } catch (error) {
98
+ const reason = error instanceof Error ? error.message : String(error);
99
+
100
+ mgr.log(`atc could not restore session ${entry.sessionID} (${reason})`);
101
+ }
102
+ }
90
103
 
91
104
  const queued = registered
92
105
  .filter((r) => r.revive || r.session.state !== 'exited')
@@ -535,6 +535,17 @@ export class SessionManager {
535
535
  }
536
536
  }
537
537
 
538
+ // An exited row never runs again until revived, and a row on a remote
539
+ // target holds a path from another machine, so neither resolves a
540
+ // repository on this one: the directory is its own root.
541
+ private pickRestoredRepoRoot(cwd: string, exited: boolean, target: string): string {
542
+ if (exited || this.targets.get(target)?.provider?.remote === true) {
543
+ return cwd;
544
+ }
545
+
546
+ return resolveRepoRoot(cwd);
547
+ }
548
+
538
549
  // Registers a fleet entry as a session with no terminal yet, under the
539
550
  // atc session id its row holds, so a fleet-wide restore can show every
540
551
  // incoming session at once; adopting it later attaches the terminal.
@@ -584,7 +595,7 @@ export class SessionManager {
584
595
  agent: entry.agent,
585
596
  pinned: entry.pinned ?? false,
586
597
  lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
587
- repoRoot: resolveRepoRoot(entry.cwd),
598
+ repoRoot: this.pickRestoredRepoRoot(entry.cwd, exited, target),
588
599
  namedBy: 'auto',
589
600
  createdAt: Date.now(),
590
601
  parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
@@ -757,6 +768,7 @@ export class SessionManager {
757
768
  s.kind = 'pty';
758
769
  s.state = 'running';
759
770
  s.lastMsg = 'revived';
771
+ s.repoRoot = provider.remote ? s.cwd : resolveRepoRoot(s.cwd);
760
772
  s.desired = 'run';
761
773
  s.suspended = false;
762
774
 
@@ -959,9 +971,8 @@ export class SessionManager {
959
971
  overrides.trustClonedWorkspace ??
960
972
  this.targets.get(target)?.options['trustClonedWorkspace'] === true;
961
973
 
962
- // The repository root resolves before the process starts: resolving it
963
- // can throw, and a spawn that throws must leave nothing running. A
964
- // remote directory is not on the daemon's machine, so it is its own root.
974
+ // A remote directory is not on the daemon's machine, so it is its own
975
+ // root.
965
976
  const repoRoot = provider.remote ? cwd : resolveRepoRoot(cwd);
966
977
  const hostKey = this.pickHostKey(id, parent, target, execution.identity);
967
978
  const auth = this.resolveHarnessAuth(adapter, provider, target);
@@ -1,3 +1,4 @@
1
+ import type { Stats } from 'node:fs';
1
2
  import { readFileSync, statSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
 
@@ -6,17 +7,33 @@ import { dirname, join } from 'node:path';
6
7
  * overlay. Walks toward the filesystem root looking for a `.git` entry: a
7
8
  * directory holding HEAD marks the repository root itself, and a linked
8
9
  * worktree's `.git` file points back at the main repository, so worktrees
9
- * cluster with it. A directory outside any repository resolves to itself.
10
+ * cluster with it. A directory outside any repository resolves to itself, and
11
+ * so does one whose path cannot be read: a filesystem error other than a
12
+ * missing entry ends the walk instead of throwing.
10
13
  */
11
14
  export function resolveRepoRoot(cwd: string): string {
12
15
  let dir = cwd;
13
16
 
14
17
  while (true) {
15
18
  const marker = join(dir, '.git');
16
- const stat = statSync(marker, { throwIfNoEntry: false });
19
+ const stat = tryStat(marker);
17
20
 
18
- if (stat !== undefined && stat.isDirectory() && hasGitHead(marker)) {
19
- return dir;
21
+ if (stat === null) {
22
+ return cwd;
23
+ }
24
+
25
+ if (stat !== undefined && stat.isDirectory()) {
26
+ // git needs HEAD in a repository, and an empty `.git` directory in a shared
27
+ // temporary directory would otherwise cluster every session under it.
28
+ const head = tryStat(join(marker, 'HEAD'));
29
+
30
+ if (head === null) {
31
+ return cwd;
32
+ }
33
+
34
+ if (head !== undefined) {
35
+ return dir;
36
+ }
20
37
  }
21
38
 
22
39
  if (stat !== undefined && stat.isFile()) {
@@ -33,13 +50,14 @@ export function resolveRepoRoot(cwd: string): string {
33
50
  }
34
51
  }
35
52
 
36
- /**
37
- * Whether a `.git` directory is a repository rather than an empty directory
38
- * that happens to carry the name. git needs HEAD in one, and a stray `.git` in
39
- * a shared temporary directory would otherwise cluster every session under it.
40
- */
41
- function hasGitHead(marker: string): boolean {
42
- return statSync(join(marker, 'HEAD'), { throwIfNoEntry: false }) !== undefined;
53
+ // The stat of a path: undefined when nothing is there, null when the path
54
+ // cannot be read at all.
55
+ function tryStat(path: string): Stats | undefined | null {
56
+ try {
57
+ return statSync(path, { throwIfNoEntry: false });
58
+ } catch {
59
+ return null;
60
+ }
43
61
  }
44
62
 
45
63
  // A linked worktree's `.git` file reads `gitdir: <main>/.git/worktrees/<name>`.