cortad 0.3.1-rc.11 → 0.3.1-rc.12

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/lib/health.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // Whether the app a run is using is alive, read the way a person would: is its process still there,
2
+ // does anything still hold its port, and only then does it answer. An app slow to answer because it
3
+ // is answering our own requests is working, not dead: resumeforge held sixteen of them on a database
4
+ // pool of five, missed two probes of 2.5 seconds, and was restarted in the middle of the run.
5
+ import { connect } from "node:net";
6
+
7
+ const FLOOR_MS = 10_000;
8
+ const CAP_MS = 120_000;
9
+ const url = (host, port) => `http://${host === "::1" ? "[::1]" : host}:${port}/`;
10
+
11
+ // Opens the socket and sends nothing. Only a refusal means nobody holds the port: a listener too
12
+ // busy to accept still holds it.
13
+ export const portHeld = (host, port, ms = 3000) => new Promise((done) => {
14
+ const socket = connect({ host, port });
15
+ const end = (held) => { socket.destroy(); done(held); };
16
+ socket.once("connect", () => end(true));
17
+ socket.once("error", (e) => end(e.code !== "ECONNREFUSED"));
18
+ socket.setTimeout(ms, () => end(true));
19
+ });
20
+
21
+ // `gone`: the process this command started has exited. `inFlight`: the run's requests the app has
22
+ // not answered yet. The probe waits three times the app's last reply to it, never under ten seconds
23
+ // or over two minutes.
24
+ export function makeHealth({ host, gone, inFlight }) {
25
+ let took = 0;
26
+ const timeout = () => Math.min(CAP_MS, Math.max(FLOOR_MS, took * 3));
27
+ return {
28
+ timeout,
29
+ // "up", "busy" (slow while the run's requests are open: never restarted), or why it is down:
30
+ // "exited", "released" (nothing holds its port) or "quiet" (no answer with nothing of ours open).
31
+ async check(port) {
32
+ if (gone()) return "exited";
33
+ if (!(await portHeld(host(), port))) return "released";
34
+ const started = Date.now();
35
+ try {
36
+ const res = await fetch(url(host(), port), { method: "GET", signal: AbortSignal.timeout(timeout()), redirect: "manual" });
37
+ await res.body?.cancel();
38
+ took = Date.now() - started;
39
+ return "up";
40
+ } catch {
41
+ return inFlight() > 0 ? "busy" : "quiet";
42
+ }
43
+ },
44
+ };
45
+ }
46
+
47
+ // The runner's own line for a restart, in the person's words.
48
+ export function downLine(state, { port, downMs, crashed = false }) {
49
+ if (crashed) return "your app crashed, starting it again";
50
+ if (state === "exited") return "your app exited, starting it again";
51
+ if (state === "released") return `nothing is listening on port ${port} any more, starting your app again`;
52
+ return `your app has not answered for ${Math.round(downMs / 1000)} seconds while no test conversation was waiting on it, starting it again`;
53
+ }
package/lib/text.mjs CHANGED
@@ -85,6 +85,8 @@ function runLines(d, prefix = "") {
85
85
  const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
86
86
  const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
87
87
  if (d.tested) lines.push(testedLine(d.tested));
88
+ // Each endpoint's first conversation, played alone before the others: whether it held and why not.
89
+ lines.push(...(d.first ?? []));
88
90
  // Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
89
91
  if (!done && d.now?.doing) lines.push(d.now.doing);
90
92
  lines.push(...waitLines(d));
@@ -138,7 +140,8 @@ function readingsLine(d) {
138
140
  // before the first turn has no turn to name, and a reason written as a sentence keeps one full stop.
139
141
  function stoppedLine(d) {
140
142
  const s = d.stopped;
141
- const turn = s.after > 0 ? ` at turn ${s.after}` : "";
143
+ // A stop before the trials, on the endpoints' first conversations, names each one instead of a turn.
144
+ const turn = s.after > 0 && !s.first ? ` at turn ${s.after}` : "";
142
145
  const why = String(s.why).replace(/\.$/, "");
143
146
  const fix = s.fix ? ` ${s.fix}` : "";
144
147
  if (d.of && d.played < d.of) return `Stopped at ${num(d.played)} of ${plural(d.of, "trial")}${SIDE[s.side] ? `, ${SIDE[s.side]}` : ""}: ${why}${turn}.${fix}`;
@@ -220,8 +223,10 @@ export function testedLine(t) {
220
223
  return `App started ${started}${t.reloads ? " and reloads itself on save" : ""}; your last change was at ${clock(t.change.at)}.`;
221
224
  }
222
225
 
223
- export const waitingText = (p, now, limitMs) =>
224
- `Your app is still starting for the ${p.kind === "verify" ? "verify" : "run"}: ${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds.\nnext: run_status pending`;
226
+ // `said`: the status's own word on the app, which knows when it already answers and only its AI
227
+ // endpoint is still being found; bloom's agent read "still starting" beside "your app is answering".
228
+ export const waitingText = (p, now, limitMs, said) =>
229
+ `${said ? String(said).replace(/\.$/, "") : `Your app is still starting for the ${p.kind === "verify" ? "verify" : "run"}`}: ${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds.\nnext: run_status pending`;
225
230
 
226
231
  // A spent plan: what the last run found, what was fixed since, what the next run would play.
227
232
  export function refusedText(d) {
package/lib/verbs.mjs CHANGED
@@ -109,7 +109,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
109
109
  if (p.jobId) return { id: p.jobId };
110
110
  if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
111
111
  if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
112
- return { text: waitingText(p, now(), READY_MS) };
112
+ return { text: waitingText(p, now(), READY_MS, latest.data.app?.state === "starting" && latest.data.app.port ? latest.data.app.said : null) };
113
113
  };
114
114
 
115
115
  const runStatus = async ({ jobId } = {}) => {
package/local.mjs CHANGED
@@ -34,6 +34,7 @@ import { listingUrl } from "./lib/listing.mjs";
34
34
  import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
35
35
  import { openSwitches } from "./lib/switches.mjs";
36
36
  import { keepData } from "./lib/data.mjs";
37
+ import { downLine, makeHealth } from "./lib/health.mjs";
37
38
  import { markRun, writesDirOf } from "./lib/writes.mjs";
38
39
 
39
40
  const argv = process.argv.slice(2);
@@ -275,6 +276,9 @@ let appGone = false;
275
276
  let lastSaid = "";
276
277
  let lastOutputAt = Date.now();
277
278
  let forgetTold = null;
279
+ // The run's requests your app has not answered yet: a slow health probe while any are open is our
280
+ // load on it, not a hang.
281
+ let answering = 0;
278
282
  const mask = (text) => { let s = String(text ?? ""); for (const v of secrets) s = s.split(v).join("[masked]"); return s; };
279
283
 
280
284
  // ---- the wire
@@ -405,12 +409,13 @@ async function verb(job) {
405
409
  const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
406
410
  return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
407
411
  };
408
- try { return await ask(); }
412
+ const counted = async () => { answering += 1; try { return await ask(); } finally { answering -= 1; } };
413
+ try { return await counted(); }
409
414
  catch (e) {
410
415
  const code = String(e.cause?.code ?? e.code ?? "");
411
416
  if (!/ECONNREFUSED|ECONNRESET|EPIPE|UND_ERR_SOCKET/.test(code)) return { error: `nothing answered at port ${port}: ${code || e.message}` };
412
417
  for (let i = 0; i < 45 && !(await answers(port)); i++) await new Promise((r) => setTimeout(r, 1000));
413
- try { return { ...(await ask()), heldForRestart: true }; }
418
+ try { return { ...(await counted()), heldForRestart: true }; }
414
419
  catch (again) { return { error: `nothing answered at port ${port}: ${String(again.cause?.code ?? again.message)}` }; }
415
420
  }
416
421
  }
@@ -854,6 +859,7 @@ async function restartApp() {
854
859
  async function restartNow() {
855
860
  if (!launched || !child) return { error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." };
856
861
  const port = app.port;
862
+ say("starting your app again so it runs the code you saved, since it does not reload by itself");
857
863
  await stopApp(child.pid);
858
864
  // Until the port is free, not merely silent: a listener still closing does not answer and still
859
865
  // holds the bind, and the restarted app was moved to the next port while the run kept knocking on
@@ -1073,26 +1079,30 @@ async function appLife() {
1073
1079
  let toldDown = false;
1074
1080
  let appTold = true;
1075
1081
  forgetTold = () => { appTold = false; };
1082
+ const health = makeHealth({ host: () => appHost, gone: () => Boolean(launched && appGone), inFlight: () => answering });
1076
1083
  for (let up = true; !closing;) {
1077
1084
  await new Promise((r) => setTimeout(r, 2000));
1078
1085
  if (closing || restarting) continue;
1079
- const now = await answers(app.port);
1080
- if (now) {
1086
+ const state = await health.check(app.port);
1087
+ if (state === "up") {
1081
1088
  downSince = 0; toldDown = false;
1082
1089
  // Said until it is heard. Said once, it was lost when their app came back while the network
1083
1090
  // was down, and the screen went on showing an app that had stopped while it answered turns.
1084
1091
  if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
1085
1092
  continue;
1086
1093
  }
1094
+ // Running, holding its port, and slow because it is answering the run's own requests: working,
1095
+ // not dead. Nothing is said and nothing restarts, and a silence after it is timed from here.
1096
+ if (state === "busy") { if (!up) downSince = Date.now(); continue; }
1087
1097
  if (up) { up = false; downSince = Date.now(); }
1088
1098
  appTold = false;
1089
1099
  const downFor = Date.now() - downSince;
1090
- // Not answering. A dev server restarting on a save is busy and says so in its output: it is
1091
- // left alone, however long its own startup takes.
1092
- // A watcher that has said its app crashed is not busy, it is waiting, and says so.
1100
+ // Not answering. A dev server restarting on a save says so in its output: it is left alone,
1101
+ // however long its own startup takes.
1102
+ // A watcher that has said its app crashed is not reloading, it is waiting, and says so.
1093
1103
  const crashed = /app crashed - waiting|Failed running|waiting for (?:file )?changes before restart/i.test(lastSaid.slice(-600));
1094
- const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
1095
- if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
1104
+ const reloading = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
1105
+ if (reloading || downFor < (crashed ? 4_000 : 10_000)) continue;
1096
1106
  // Down for real. The screen is told, so it never shows an app that is not there.
1097
1107
  // `exited`: the process this command started is gone or its watcher said it crashed, not a process
1098
1108
  // running and not answering. The run tells the two apart: one is their crash, the other may be our
@@ -1108,7 +1118,7 @@ async function appLife() {
1108
1118
  restarting = true;
1109
1119
  try {
1110
1120
  if (child?.pid) await stopApp(child.pid);
1111
- say("your app stopped answering, starting it again");
1121
+ say(downLine(state, { port: app.port, downMs: downFor, crashed }));
1112
1122
  let back = launched ? await launch(180_000) : await startApp();
1113
1123
  while (!back.port && !closing) {
1114
1124
  await call("POST", `/local/${box}/stopped`, { said: mask(back.tail || back.said || lastSaid).slice(-2000) }).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.11",
3
+ "version": "0.3.1-rc.12",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -12,6 +12,7 @@
12
12
  "lib/data.mjs",
13
13
  "lib/failed-text.mjs",
14
14
  "lib/fresh.mjs",
15
+ "lib/health.mjs",
15
16
  "lib/home.mjs",
16
17
  "lib/identities.mjs",
17
18
  "lib/keys.mjs",