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 +53 -0
- package/lib/text.mjs +8 -3
- package/lib/verbs.mjs +1 -1
- package/local.mjs +20 -10
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
1080
|
-
if (
|
|
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
|
|
1091
|
-
//
|
|
1092
|
-
// A watcher that has said its app crashed is not
|
|
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
|
|
1095
|
-
if (
|
|
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(
|
|
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.
|
|
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",
|