@assuredloop/orca-bot-kit 0.36.1 → 0.38.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.
package/README.md CHANGED
@@ -474,6 +474,18 @@ It closes the session's tab — one tab, by its own handle — and opens a new o
474
474
  with the conversation the book holds, so the session comes back as itself. With
475
475
  no `--session` it does that for every session of the bot.
476
476
 
477
+ Closing the old tab ends the harness there, and the background shells it ran
478
+ end with it: a timer, a watch, a long build. So once a session whose tab the
479
+ restart closed is back with its conversation, the kit types one line into its
480
+ new tab. A session whose tab was already closed, or that starts fresh, gets no
481
+ line. The line says that its background shells
482
+ ended, that the kit cannot tell which ones ran or whether a scheduled job still
483
+ runs, and that it should look at its scheduled jobs before it makes one again.
484
+ It also asks the session to read its mail and do what it asks. The line goes in
485
+ only when the tab shows no question, form or menu, within 20 seconds. The
486
+ report says under each tab whether the line went in. When it did not, it says
487
+ why, and you tell the session yourself.
488
+
477
489
  What it will not do, in order of how much it would cost you. It closes only a
478
490
  tab your book names, so Bot Father's ops tab and anything you opened yourself
479
491
  are left alone. It will not close a tab whose conversation the book cannot name:
@@ -516,7 +528,15 @@ effort and context go back to the new harness's defaults unless you give them
516
528
  too. A pause closes the tabs the way a restart does, with the
517
529
  same refusals, and one more: it also refuses a tab with only its shell in front
518
530
  whose conversation the book does not name. `obk up` leaves what is paused
519
- closed until `obk unpause` brings it back with its conversations. Retiring a
531
+ closed until `obk unpause` brings it back with its conversations. The closed
532
+ tab ended the background shells of each paused session, as a restart does. So
533
+ the unpause tells each session it took off pause and resumed, in one typed
534
+ line, as a restart does: its old tab is closed and its background shells
535
+ ended, and it looks at its scheduled jobs and its mail. The report says under
536
+ each tab whether the line went in. In the `--json` answer, `restartLine` means
537
+ the same after an unpause as after a restart: `true` when the line went in, or
538
+ `false` with the reason in `restartLineTrouble`, and then you tell the session
539
+ yourself. Retiring a
520
540
  session takes it off the bot and keeps its conversations in the book under
521
541
  `retired`. Once its tab is closed, it stops what the session left running in
522
542
  its work dir: each of your processes whose working folder is there, and what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.36.1",
3
+ "version": "0.38.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.js CHANGED
@@ -690,7 +690,7 @@ const commands = {
690
690
  const answer = { bots, bot, session, changed, created: [], completed: [], ...up };
691
691
  return {
692
692
  answer,
693
- lines: tabLines(answer, `${changed ? 'Unpaused' : 'Not paused, so brought up as it is'}: ${what}. Your bots folder: ${bots}`),
693
+ lines: tabLines(answer, `${changed ? 'Unpaused' : 'Not paused, so brought up as it is'}: ${what}. Your bots folder: ${bots}`, 'pause'),
694
694
  };
695
695
  },
696
696
 
@@ -1918,7 +1918,7 @@ function settingsLine({ bot, session, running, settings, rules }) {
1918
1918
  return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
1919
1919
  }
1920
1920
 
1921
- function tabLines({ bots, created, completed, rules, skills, permissions = [], tabs, paused = [], projects = [], found = [] }, summary) {
1921
+ function tabLines({ bots, created, completed, rules, skills, permissions = [], tabs, paused = [], projects = [], found = [] }, summary, closedBy = 'restart') {
1922
1922
  const lines = [
1923
1923
  ...created.map((entry) => `created ${entry}`),
1924
1924
  ...completed.map((entry) => `completed ${entry}`),
@@ -1929,7 +1929,7 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
1929
1929
 
1930
1930
  for (const tab of tabs) {
1931
1931
  lines.push(`${tab.created ? 'opened' : 'found '} ${tab.title} tab ${tab.tabId} terminal ${tab.terminal}`);
1932
- lines.push(...harnessLines(tab, bots));
1932
+ lines.push(...harnessLines(tab, bots, closedBy));
1933
1933
  if (tab.noMailbox) {
1934
1934
  lines.push(` it has no mailbox: ${noMailboxYet({ bots, bot: tab.bot, session: tab.name })}`);
1935
1935
  }
@@ -1962,7 +1962,7 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
1962
1962
  * The caller decides what to do next from these lines, so they say both what
1963
1963
  * was typed and what was seen afterwards, and never one in place of the other.
1964
1964
  */
1965
- function harnessLines(tab, bots) {
1965
+ function harnessLines(tab, bots, closedBy) {
1966
1966
  if (!tab.created || tab.name === null) return [];
1967
1967
 
1968
1968
  // Nothing was typed into it, so there is nothing more to say about a harness
@@ -1988,6 +1988,7 @@ function harnessLines(tab, bots) {
1988
1988
  ` Look at it: ${lookAt(tab.terminal)}`,
1989
1989
  ...ANSWER_IT,
1990
1990
  ...promptLines(tab),
1991
+ ...restartLines(tab, closedBy),
1991
1992
  ];
1992
1993
  }
1993
1994
 
@@ -2009,7 +2010,7 @@ function harnessLines(tab, bots) {
2009
2010
  ...ANSWER_IT,
2010
2011
  ];
2011
2012
 
2012
- lines.push(...promptLines(tab), ...listLines(tab));
2013
+ lines.push(...promptLines(tab), ...listLines(tab), ...restartLines(tab, closedBy));
2013
2014
  if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
2014
2015
  return lines;
2015
2016
  }
@@ -2024,6 +2025,18 @@ function listLines(tab) {
2024
2025
  return [];
2025
2026
  }
2026
2027
 
2028
+ /**
2029
+ * Whether a session `obk restart` resumed was typed the line that tells it what
2030
+ * the restart ended (#726), or one `obk unpause` resumed, what the pause ended
2031
+ * (#732): `closedBy` names which. When it was not, the caller is the one left
2032
+ * to tell it.
2033
+ */
2034
+ function restartLines(tab, closedBy) {
2035
+ if (tab.restartLine === true) return [` it was typed one line that tells it what the ${closedBy} ended: its background shells, and to look at its scheduled jobs and its mail.`];
2036
+ if (tab.restartLine === false) return [` it was not told what the ${closedBy} ended (${tab.restartLineTrouble}): tell it yourself that its background shells ended, and to look at its scheduled jobs and its mail.`];
2037
+ return [];
2038
+ }
2039
+
2027
2040
  /**
2028
2041
  * Whether the session got its start prompt, as its own record has it (#274):
2029
2042
  * received, or not confirmed yet. Nothing for a session that was told nothing.
package/src/message.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
17
17
  // window has not loaded, so the send says when the receiver's is one (#579).
18
18
 
19
+ import { randomUUID } from 'node:crypto';
19
20
  import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
21
  import { homedir } from 'node:os';
21
22
  import path from 'node:path';
@@ -24,7 +25,7 @@ import { setTimeout as pause } from 'node:timers/promises';
24
25
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
25
26
  import { botDir, botNames, readBot } from './bot.js';
26
27
  import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
27
- import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
28
+ import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, readsCarryRequest, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
28
29
 
29
30
  /**
30
31
  * How much of a message travels as itself. Above this it is written to a file
@@ -365,10 +366,18 @@ function mailOf(bots, who, peek, until) {
365
366
  // acknowledging is handed over again to the next. From 1.4.246 a read
366
367
  // acknowledges the batch it hands over and says so (`deliveryAcknowledged`),
367
368
  // and an acknowledgement answers with no batch, so the next is read again
368
- // (#729; read in the 1.4.246 and 1.4.255 source).
369
+ // (#729; read in the 1.4.246 and 1.4.255 source). So a read that is not a
370
+ // peek is asked again, under the same request id, when Orca does not answer
371
+ // it in time (#735).
369
372
  const messages = [];
370
373
  const read = { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
371
- let found = readMailbox(who.mailbox, { peek, handle }, timeLeft(until));
374
+ let found;
375
+ try {
376
+ found = peek ? readMailbox(who.mailbox, { peek, handle }, timeLeft(until)) : take(who.mailbox, handle, until, timeLeft(until));
377
+ } catch (error) {
378
+ if (peek || error.code !== TIMED_OUT) throw error;
379
+ return { ...read, trouble: `${error.message} Orca may have taken letters as read without the kit seeing them. Other mail waits for the next check.` };
380
+ }
372
381
  for (;;) {
373
382
  const batch = (found.messages ?? []).map((message) => asMessage(bots, message));
374
383
  if (peek || !found.deliveryId || batch.length === 0) {
@@ -383,7 +392,7 @@ function mailOf(bots, who, peek, until) {
383
392
  return { ...read, trouble: `${who.bot}/${who.session} has more mail than one check reads in ${HOLD_MS / 1000} seconds, so it stopped there. The rest is still waiting: check again.` };
384
393
  }
385
394
  try {
386
- found = readMailbox(who.mailbox, { handle }, wait);
395
+ found = take(who.mailbox, handle, until, wait);
387
396
  } catch (error) {
388
397
  // A read Orca did not answer about may have taken the next batch all
389
398
  // the same, and taken is read: that batch is not handed over again.
@@ -417,6 +426,31 @@ function mailOf(bots, who, peek, until) {
417
426
  }
418
427
  }
419
428
 
429
+ /**
430
+ * Take the next batch of `mailbox`, read as `handle`, under a request id of
431
+ * its own, the first call given `options` (#735). Orca keeps the answer of a
432
+ * read under its id: the same read asked again with that id answers the same
433
+ * batch and takes nothing new, waits for the first if it is still running,
434
+ * and runs as new if the first never reached Orca (read in the 1.4.246 and
435
+ * 1.4.255 source). So a read Orca does not answer in time is asked again with
436
+ * the same id while the check's hold lasts, and a batch Orca took as read is
437
+ * shown once. No other call is asked again: a send, a bind or an ack Orca did
438
+ * not answer about is not sent twice (#734).
439
+ */
440
+ function take(mailbox, handle, until, options) {
441
+ const request = randomUUID();
442
+ for (;;) {
443
+ try {
444
+ return readMailbox(mailbox, { handle, request }, options);
445
+ } catch (error) {
446
+ // A read that went without the id is not asked again (#734).
447
+ if (error.code !== TIMED_OUT || !readsCarryRequest()) throw error;
448
+ options = timeLeft(until, false);
449
+ if (options === undefined) throw error;
450
+ }
451
+ }
452
+ }
453
+
420
454
  /**
421
455
  * How long a check holds the session's turn, all its Orca calls together. Well
422
456
  * inside the minute `up` waits for the turn before it writes a new tab, so a
package/src/orca.js CHANGED
@@ -1021,6 +1021,36 @@ function panePid(ptyId, readMs) {
1021
1021
  return { pane };
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * The ids of the ptys Orca holds a process for, from `diagnostics memory`, as
1026
+ * a set, or undefined when the kit cannot tell (#733). Each session there is a
1027
+ * pty of Orca's own registry, which drops a pty once its process has ended
1028
+ * (read in the 1.4.255 bundle; seen live, the pty went from the answer as its
1029
+ * pane process and shell ended). An Orca that refuses the command, or an
1030
+ * answer in another shape, cannot tell. Nor can an answer with no worktree at
1031
+ * all: Orca answers a snapshot that failed with an empty list (read in the
1032
+ * 1.4.255 bundle), and the kit cannot tell that from a machine with no
1033
+ * terminal.
1034
+ */
1035
+ export function livePtys() {
1036
+ let worktrees;
1037
+ try {
1038
+ worktrees = orca(['diagnostics', 'memory']).worktrees;
1039
+ } catch {
1040
+ return undefined;
1041
+ }
1042
+ if (!Array.isArray(worktrees) || worktrees.length === 0) return undefined;
1043
+ const ids = new Set();
1044
+ for (const worktree of worktrees) {
1045
+ if (!Array.isArray(worktree?.sessions)) return undefined;
1046
+ for (const session of worktree.sessions) {
1047
+ if (typeof session?.sessionId !== 'string') return undefined;
1048
+ ids.add(session.sessionId);
1049
+ }
1050
+ }
1051
+ return ids;
1052
+ }
1053
+
1024
1054
  /** The `lsof` and `stty` the kit asks, by their fixed paths, as `ps`. */
1025
1055
  export const LSOF = '/usr/sbin/lsof';
1026
1056
  const STTY = '/bin/stty';
@@ -1299,9 +1329,37 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
1299
1329
  * and says so with `deliveryAcknowledged: true` (#729). Read as
1300
1330
  * the terminal `handle` when one is given, which is how a read works from a tab
1301
1331
  * bound to another Run; otherwise as this process's own terminal.
1332
+ *
1333
+ * A read given a `request` id carries it as `--retry-request`: Orca keeps its
1334
+ * answer under that id, and the same read asked again with it answers the
1335
+ * same batch and takes nothing new (#735). An Orca that refuses the flag as
1336
+ * unknown did nothing, and is asked again without it, as for `--full`.
1337
+ */
1338
+ export function readMailbox(id, { peek = false, handle, request } = {}, options) {
1339
+ const args = ['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])];
1340
+ if (request !== undefined && retryKnown) {
1341
+ try {
1342
+ return orchestration([...args, '--retry-request', request], options);
1343
+ } catch (error) {
1344
+ if (error.code !== 'invalid_argument' || error.reason !== RETRY_UNKNOWN) throw error;
1345
+ retryKnown = false;
1346
+ }
1347
+ }
1348
+ return orchestration(args, options);
1349
+ }
1350
+
1351
+ /** Orca's own words when its CLI does not know `--retry-request` on `check`: nothing was done. */
1352
+ const RETRY_UNKNOWN = 'Unknown flag --retry-request for command: orchestration check';
1353
+
1354
+ /** Whether this Orca may know `--retry-request` on `check`: false once it has refused it. */
1355
+ let retryKnown = true;
1356
+
1357
+ /**
1358
+ * Whether a read given a request id still carries it. Once Orca has refused
1359
+ * the flag, a read goes without it, and a read with no id is never asked again
1360
+ * after a time-out: it would take another batch, not answer the first (#735).
1302
1361
  */
1303
- export const readMailbox = (id, { peek = false, handle } = {}, options) =>
1304
- orchestration(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
1362
+ export const readsCarryRequest = () => retryKnown;
1305
1363
 
1306
1364
  /**
1307
1365
  * The newest `limit` messages in a mailbox, newest first, with every column
package/src/pause.js CHANGED
@@ -40,10 +40,14 @@ export async function pauseSessions(bots, { bot, session }) {
40
40
  */
41
41
  export async function unpauseSessions(bots, { bot, session }) {
42
42
  const home = fleetMember(bots, bot, 'unpause', session);
43
- sessionsOf(readBot(home, bot), session);
43
+ const sessions = sessionsOf(readBot(home, bot), session);
44
44
 
45
45
  const changed = markPaused(bots, bot, session, false);
46
- return { bot, session, changed, ...(await bringUp(bots, { bot, session })) };
46
+ // The sessions this took the pause off are told, once they are resumed, what
47
+ // their closed tab ended (#732). One still paused on its own is not brought
48
+ // up, so it is told nothing; nothing is told when nothing was paused.
49
+ const unpaused = changed ? new Set(sessions.map((one) => one.name)) : undefined;
50
+ return { bot, session, changed, ...(await bringUp(bots, { bot, session, unpaused })) };
47
51
  }
48
52
 
49
53
  /**
package/src/restart.js CHANGED
@@ -34,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
34
34
  import { bookFile, readBook } from './book.js';
35
35
  import { botDir, readBot } from './bot.js';
36
36
  import { ownCli, shellWord } from './launch.js';
37
- import { closeTab, closeTerminal, findProject, frontOfTab, orcaCli, tabs } from './orca.js';
37
+ import { closeTab, closeTerminal, findProject, frontOfTab, livePtys, orcaCli, tabs } from './orca.js';
38
38
  import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
39
39
 
40
40
  /**
@@ -69,7 +69,9 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
69
69
 
70
70
  const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true), { quit: true });
71
71
  const closed = await closeTabs(home, going, bots, name, commandLine('restart', bots, name, onlySession));
72
- return { closed, ...(await bringUp(bots, { bot: name, session: onlySession })) };
72
+ // The sessions whose tabs this closed are the ones the restart ended
73
+ // something of, and the ones told so once they are back (#726).
74
+ return { closed, ...(await bringUp(bots, { bot: name, session: onlySession, restarted: new Set(closed.map((tab) => tab.name)) })) };
73
75
  }
74
76
 
75
77
  /**
@@ -114,9 +116,11 @@ export function tabsToClose(bots, name, home, sessions, { keepless = false, quit
114
116
  * Close the tabs `tabsToClose` gave, one at a time by their own handles, and
115
117
  * wait until Orca's listing agrees they are gone. Returns one entry per tab.
116
118
  * `again` is the command that asked, as the user would run it again: what to do
117
- * once a tab Orca would not close has been closed by hand.
119
+ * once a tab Orca would not close has been closed by hand. With `retiring`, the
120
+ * caller starts no harness in the tabs' place, so a tab Orca still lists after
121
+ * the wait may count as gone once its process is shown to have ended (#733).
118
122
  */
119
- export async function closeTabs(home, going, bots, name, again) {
123
+ export async function closeTabs(home, going, bots, name, again, { retiring = false } = {}) {
120
124
  const closed = [];
121
125
  for (const { name: session, tab } of going) {
122
126
  try {
@@ -125,12 +129,12 @@ export async function closeTabs(home, going, bots, name, again) {
125
129
  // A tab that would not close still holds its session, so nothing is
126
130
  // opened after this: two harnesses on one conversation is worse than a
127
131
  // restart that did not happen.
128
- throw new Error(alsoClosed(error.message, closed, bots, name));
132
+ throw new Error(alsoClosed(error.message, closed, bots, name, retiring ? again : undefined));
129
133
  }
130
134
  closed.push({ bot: name, name: session, tabId: tab.tabId, terminal: tab.handle });
131
135
  }
132
136
 
133
- await gone(home, closed, bots, name);
137
+ await gone(home, closed, bots, name, retiring ? { again, ptys: new Map(going.map(({ tab }) => [tab.tabId, tab.ptyId])) } : undefined);
134
138
  return closed;
135
139
  }
136
140
 
@@ -185,8 +189,21 @@ const ASK_MS = 100;
185
189
  * A listing that never catches up is not something the kit can put right, so it
186
190
  * stops and says which tab it is waiting on. The conversation is in the book,
187
191
  * which is the way back to it.
192
+ *
193
+ * A retire, given as `retire: { again, ptys }`, starts nothing in the tabs'
194
+ * place, and what it must not leave behind is a live harness (#733). Orca
195
+ * 1.4.255 can go on listing a closed tab for minutes, `connected: false`,
196
+ * after its process has ended: a pane its window still holds (tech notes,
197
+ * section 1). That row alone does not prove the process ended, since a pane
198
+ * can say it is not connected while its pty is live, and so Orca's own list
199
+ * of live ptys is read too. Once the wait is over, a tab of the retire's
200
+ * counts as gone when every row Orca lists for it says `connected: false`
201
+ * with the pty the kit closed, and Orca holds no process for that pty.
202
+ * Anything the kit cannot read is "cannot tell", and the retire stops as
203
+ * before, with the retire to run again: the user wants the session gone, not
204
+ * back.
188
205
  */
189
- async function gone(home, closed, bots, bot) {
206
+ async function gone(home, closed, bots, bot, retire) {
190
207
  // Nothing closed is nothing to wait for, and a bot that has never been up has
191
208
  // no Orca project to ask: Orca refuses the listing (tech notes, section 1).
192
209
  if (closed.length === 0) return;
@@ -194,18 +211,40 @@ async function gone(home, closed, bots, bot) {
194
211
  const ids = closed.map((tab) => tab.tabId);
195
212
 
196
213
  for (;;) {
197
- const there = new Set(tabs(home).map((tab) => tab.tabId));
198
- const left = ids.filter((id) => there.has(id));
214
+ const listed = tabs(home);
215
+ const there = new Set(listed.map((tab) => tab.tabId));
216
+ let left = ids.filter((id) => there.has(id));
199
217
  if (left.length === 0) return;
200
218
 
201
219
  if (Date.now() >= until) {
202
220
  const one = left.length === 1;
203
- throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
221
+ if (retire === undefined) {
222
+ throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
223
+ }
224
+ left = stillRunning(listed, left, retire.ptys);
225
+ if (left.length === 0) return;
226
+ const few = left.length === 1;
227
+ throw new Error(`Orca answered the close for ${few ? 'this tab' : 'these tabs'} and is still listing ${few ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later, and the kit cannot tell that ${few ? 'its harness has' : 'their harnesses have'} stopped: ${left.join(', ')}. The retire stopped there. Once Orca has caught up, run the same retire again: ${retire.again}`);
204
228
  }
205
229
  await pause(ASK_MS);
206
230
  }
207
231
  }
208
232
 
233
+ /**
234
+ * Of the tab ids `left`, the ones Orca's listing `listed` does not show to have
235
+ * ended: a tab ended when each row for it says `connected: false` with the pty
236
+ * `ptys` gives it, and `livePtys` reads and lacks that pty.
237
+ */
238
+ function stillRunning(listed, left, ptys) {
239
+ const live = livePtys();
240
+ if (live === undefined) return left;
241
+ return left.filter((id) => {
242
+ const pty = ptys.get(id);
243
+ const ended = (tab) => tab.connected === false && tab.ptyId === pty;
244
+ return typeof pty !== 'string' || live.has(pty) || !listed.filter((tab) => tab.tabId === id).every(ended);
245
+ });
246
+ }
247
+
209
248
  /**
210
249
  * A session the kit will not close a tab on: it is live, and the book does not
211
250
  * say which conversation is in it — a Codex hooks file trusted too late is
@@ -225,10 +264,17 @@ function cannotComeBack(bots, bot, name, was) {
225
264
  ].join(' ');
226
265
  }
227
266
 
228
- /** What a failed close owes whoever is reading: the tabs that went before it. */
229
- function alsoClosed(why, closed, bots, bot) {
267
+ /**
268
+ * What a failed close owes whoever is reading: the tabs that went before it.
269
+ * For a retire, given as the command to run again, the way on is that retire,
270
+ * not `up`: the user wants those sessions gone, not back (#733).
271
+ */
272
+ function alsoClosed(why, closed, bots, bot, retireAgain) {
230
273
  if (closed.length === 0) return why;
231
274
 
232
275
  const one = closed.length === 1;
276
+ if (retireAgain !== undefined) {
277
+ return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it, and nothing more was retired. Once that tab can be closed, run the same retire again: ${retireAgain}`;
278
+ }
233
279
  return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings ${one ? 'it' : 'them'} back with ${one ? 'its' : 'their'} conversation.`;
234
280
  }
package/src/retire.js CHANGED
@@ -45,9 +45,11 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
45
45
  * removed, and `processes` when it has a work dir: what `stopProcesses`
46
46
  * stopped and left of what it started there (#537). `retiredWith` holds one
47
47
  * `{ bot, session, maker, closed }` for each session that went with it,
48
- * deepest first, with its own `promptsLeft` and `processes`.
48
+ * deepest first, with its own `promptsLeft` and `processes`. `again` is the
49
+ * command the user ran, as they would run it again: what a refusal names, for
50
+ * this session and each that goes with it.
49
51
  */
50
- export async function retireSession(bots, { bot, session }) {
52
+ export async function retireSession(bots, { bot, session }, again = commandLine('retire', bots, bot, session)) {
51
53
  const home = fleetMember(bots, bot, 'retire', session);
52
54
  const known = readBot(home, bot);
53
55
  const sessions = sessionsOf(known, session);
@@ -59,11 +61,11 @@ export async function retireSession(bots, { bot, session }) {
59
61
  let processes;
60
62
  try {
61
63
  for (const { name } of made) {
62
- const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
64
+ const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name }, again);
63
65
  retiredWith.push(...theirs, { ...gone, maker: session });
64
66
  }
65
67
 
66
- closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
68
+ closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, again, { retiring: true });
67
69
  // Only once its tab is closed: its harness starts nothing more.
68
70
  const dir = workDirOf(sessions[0], home);
69
71
  if (dir !== undefined) processes = await stopProcesses([{ session, dir }]);
@@ -137,7 +139,7 @@ export async function retireBot(bots, { bot }) {
137
139
  // taken away with one of them still open. `closeTabs` waits until Orca agrees
138
140
  // they are gone.
139
141
  const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
140
- const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot));
142
+ const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot), { retiring: true });
141
143
  // Once their tabs are closed, and before the folder moves (#537).
142
144
  const dirs = known.sessions.flatMap((session) => {
143
145
  const dir = workDirOf(session, home);
package/src/temp.js CHANGED
@@ -367,7 +367,8 @@ const promptLeft = (why, written, left) =>
367
367
  export async function retireTemp(bots, { tab, name }) {
368
368
  const caller = callerIn(bots, tab, 'retire');
369
369
  ownTemp(caller, name, 'retire', 'Nothing was retired.');
370
- return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
370
+ const again = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${name}`;
371
+ return { ...(await retireSession(bots, { bot: caller.bot, session: name }, again)), maker: caller.session };
371
372
  }
372
373
 
373
374
  /**
package/src/up.js CHANGED
@@ -49,7 +49,7 @@ const SECOND_LOOK_MS = 2000;
49
49
  * one bot and `session` one of its sessions, for a caller that wants one thing
50
50
  * back rather than the fleet.
51
51
  */
52
- export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {}) {
52
+ export async function bringUp(bots, { bot: onlyBot, session: onlySession, restarted, unpaused } = {}) {
53
53
  const named = botsNamed(bots, onlyBot);
54
54
  if (onlySession !== undefined && onlyBot === undefined) {
55
55
  throw new Error('--session needs --bot: say which bot the session belongs to.');
@@ -72,7 +72,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
72
72
  for (const session of sessionsOf(bot, onlySession)) {
73
73
  if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
74
74
  }
75
- const up = await bringUpBot(bots, home, bot, onlySession);
75
+ const up = await bringUpBot(bots, home, bot, onlySession, { restarted, unpaused });
76
76
  report.push(...up.tabs);
77
77
  if (up.project !== undefined) projects.push(up.project);
78
78
  }
@@ -230,7 +230,7 @@ export function sessionsOf(bot, onlySession) {
230
230
  return named;
231
231
  }
232
232
 
233
- async function bringUpBot(bots, home, bot, onlySession) {
233
+ async function bringUpBot(bots, home, bot, onlySession, { restarted, unpaused } = {}) {
234
234
  const name = bot.name;
235
235
  const title = displayName(name);
236
236
  const sessions = awake(sessionsOf(bot, onlySession));
@@ -250,7 +250,10 @@ async function bringUpBot(bots, home, bot, onlySession) {
250
250
 
251
251
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
252
252
  const report = [];
253
- for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title));
253
+ // Which command closed the session's old tab, when one did and the session
254
+ // is to be told what that ended (#726, #732).
255
+ const endedBy = (name) => (restarted?.has(name) ? 'restart' : unpaused?.has(name) ? 'unpause' : undefined);
256
+ for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title, endedBy(session.name)));
254
257
 
255
258
  // The ops tab, and the whole of what the kit knows about it: Bot Father's
256
259
  // project needs one tab that is not a session, for work across the fleet. Any
@@ -274,7 +277,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
274
277
  return { tabs: report, project: change === undefined && windowTrouble === undefined ? undefined : project };
275
278
  }
276
279
 
277
- async function bringUpSession(bots, home, live, session, bot, title) {
280
+ async function bringUpSession(bots, home, live, session, bot, title, endedBy) {
278
281
  const harness = harnessOf(session, bot.harness);
279
282
  const book = readBook(home);
280
283
  const was = book.sessions[session.name];
@@ -431,11 +434,21 @@ async function bringUpSession(bots, home, live, session, bot, title) {
431
434
  ? undefined
432
435
  : await heldInRecord(home, session.name, harness, launch.prompt, { launched, running: tui.running === true });
433
436
 
437
+ // A session whose tab `obk restart` closed lost the background shells its
438
+ // harness ran there, and nothing else tells it (#726). So does one that
439
+ // `obk unpause` took off pause: its old tab is closed (#732). So once it is
440
+ // resumed it is typed one line that says so, through the same gate, whatever
441
+ // its harness. For Codex that line is its first turn too, so it takes the
442
+ // place of the list line below.
443
+ const told = endedBy === undefined || launch.resume === undefined ? undefined
444
+ : tui.running === true ? await typeListLine(home, session.name, made.tabId, { line: (endedBy === 'unpause' ? unpauseLine : restartLine)(bots) })
445
+ : { typed: false, why: 'its harness did not come up in the new tab' };
446
+
434
447
  // A Codex session resumed here is not among Orca's agents until its first
435
448
  // turn: Codex fires its SessionStart then and not at the resume, seen live on
436
449
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
437
450
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
438
- const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
451
+ const list = told === undefined && harness === 'codex' && launch.resume !== undefined && tui.running === true
439
452
  ? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
440
453
  : undefined;
441
454
 
@@ -456,6 +469,8 @@ async function bringUpSession(bots, home, live, session, bot, title) {
456
469
  unclaimed: which.unclaimed,
457
470
  listLine: list?.typed,
458
471
  listLineTrouble: list?.why,
472
+ restartLine: told?.typed,
473
+ restartLineTrouble: told?.why,
459
474
  });
460
475
  }
461
476
 
@@ -516,6 +531,42 @@ const listLine = (bots) => 'obk: this session was resumed in a new tab, and the
516
531
  + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
517
532
  + ' That includes mail you read just before this line. If there is none, reply "ok".';
518
533
 
534
+ /**
535
+ * The line a session is typed once `obk restart` has resumed it in a new tab,
536
+ * in the architect's ruling (#726). It says only what the kit knows: the old
537
+ * tab's harness ended, and its background shells with it. The kit cannot tell
538
+ * which shells ran, nor whether a scheduled job survived (one was seen to), so
539
+ * the session looks before it makes a job again. The mail part is the list
540
+ * line's as #709 left it, but for its last sentence: "If nothing needs doing"
541
+ * where the list line says "If there is none". This line also asks the session
542
+ * to start its shells again, and a session with no mail must not reply "ok"
543
+ * and stop there (the architect's ruling on PR #731).
544
+ */
545
+ const restartLine = (bots) => 'obk: obk restart closed this session\'s old tab and resumed the conversation in this new one.'
546
+ + ' Closing the old tab ended the harness there, and the background shells it ran ended with it.'
547
+ + whatToDoAfterTheClose(bots);
548
+
549
+ /**
550
+ * What the restart line and the unpause line both say after their first two
551
+ * sentences: what the kit cannot tell, and what the session does about it.
552
+ */
553
+ const whatToDoAfterTheClose = (bots) => ' The kit cannot tell which shells ran, or whether a scheduled job of the harness still runs.'
554
+ + ' Look at the harness\'s scheduled jobs before you make one again, so that only one of each runs.'
555
+ + ' Start again the shells you still need.'
556
+ + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
557
+ + ' That includes mail you read just before this line. If nothing needs doing, reply "ok".';
558
+
559
+ /**
560
+ * The line a session is typed once `obk unpause` has taken it off pause and
561
+ * resumed it in a new tab, in the architect's ruling (#732). Its first two
562
+ * sentences take the place of the restart line's: the kit cannot tell whether
563
+ * the pause closed the old tab or it was closed before, and the words are true
564
+ * either way. The rest is the restart line's, word for word.
565
+ */
566
+ const unpauseLine = (bots) => 'obk: obk unpause took this session off pause and resumed its conversation in this new tab.'
567
+ + ' Its old tab is closed, which ended the harness there, and the background shells it ran ended with it.'
568
+ + whatToDoAfterTheClose(bots);
569
+
519
570
  /**
520
571
  * How long a resumed Codex tab is given to be one the kit may type into: live,
521
572
  * Orca named the agent 7 to 11 s after the restart (#226), and the gate waits
@@ -969,7 +1020,7 @@ export const promptPath = (bots, bot, session) =>
969
1020
  export const oldPromptPath = (bots, bot, session) =>
970
1021
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
971
1022
 
972
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
1023
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, restartLine, restartLineTrouble, notLaunched }) {
973
1024
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
974
1025
  // A tab whose shell was not ready for the launch line, and why: nothing was
975
1026
  // typed into it (#498).
@@ -981,6 +1032,8 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
981
1032
  // and why not when it was not (#226).
982
1033
  if (listLine !== undefined) made.listLine = listLine;
983
1034
  if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
1035
+ if (restartLine !== undefined) made.restartLine = restartLine;
1036
+ if (restartLineTrouble !== undefined) made.restartLineTrouble = restartLineTrouble;
984
1037
  // The id the book held that had no conversation behind it, now in the history.
985
1038
  if (noConversation !== undefined) made.noConversation = noConversation;
986
1039
  // The id the book held that ran on another harness, the one this session