fleet: sync e7b0635 runtime while preserving HLS evidence

This commit is contained in:
thePR0M3TH3AN committed 2026-10-24 15:44:39 -04:00
commit 5f8e58bec0
17 files changed
+360 -67

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Hourly check-in — 2026-10-24 19:22 UTC
- Read PROGRAM, WIND-DOWN, current RULE-CHANGES, repo memory, active backlog,
task lines, dispatch mailbox (empty), and landing results/log.
- `node tools/fleet/bin/doctor.mjs --json`: exit 0 at 19:22:16Z. Dispatch DIRTY4,
client ONE-DISK2, storage clean, QA DIRTY3 plus one WAITING line. Existing work
was preserved. Doctor also reports unowned paths; ownership was not changed.
- Every WAITING line read: QA waits on dispatch since 03:00:28Z, over 16 hours.
Sent dispatch one scheduler mailbox notice at 19:23:03Z asking acknowledgement
and resolution or explicit parking. Recorded delivery in today's sent ledger;
retained the heavy HOLD and granted no execution slot. Consumption unverified.
- `node tools/fleet/bin/landq.mjs status`: exit 0, queue 0, lander NOT running.
Appended dated owner line; an empty-queue drain normally exits by design.
- Sole failed result `d7184f07bd` is publication divergence, already known to
submitter and recovered per fleet/LOG.md. Append-only log also preserves two
red gates overwritten by later same-SHA success results. Added these to
STRIKES with environment/thermal context, one per area this week. No tier
move threshold reached. No outstanding conflict found in retained records.
- Usage governor: `usage.enabled=false`, `modules.usageGovernor=false`;
configured wind-down threshold 95%. Adapter usage hook was not called;
usage-based PAUSED step does not apply.
- No product edits, tests, builds, browser runs, push, deploy, approvals,
scheduler installation or interactive session starts. Main/product health,
usage percentage, live playback, owner acknowledgement and message consumption
were not verified. Documentation landing is checked separately after submit.
+14
View File
@@ -10,3 +10,17 @@ Changes go into fleet.config.json (area `tier` / `context`) and need a fresh sta
| date | area | tier | strike | evidence (SHA, check, who caught it) |
|---|---|---|---|---|
| 2026-10-20 | dispatch | strong (hub) | red landing | `47953970ec`, landq.log 13:28:15.437Z, gate exit 1; inherited NODE_TEST_CONTEXT/capture failure, not a product assertion failure. Same SHA subsequently landed `bbc1be79` after environment correction; fleet/LOG.md trial intervention. |
| 2026-10-23 | client-reliability | standard (feature) | red landing | `280f043a66`, landq.log 12:46:14.141Z, gate exit 1 after worker thermal SIGINT (398/437 files); overlapping kit validation documented in fleet/LOG.md. Later same-SHA success does not erase this event. |
2026-10-24 hourly audit: one confirmed strike per area in the current week;
no two-strike tier proposal. Historical events recovered from the append-only
landing log because a successful same-SHA retry replaces its result JSON.
`d7184f07bd` failed publication at 2026-10-23T12:39:19Z due to target-branch
divergence during the gate; fleet/LOG.md records submitting-area notification
and recovery. This infrastructure publication failure is not counted as a
second client red gate or reviewer rejection. No withdrawn submissions or
conflicts appear in the retained landing log; no additional confirmed reviewer,
peer false-green, or owner-regression event was identified in the checked
dispatch mailbox, PROGRAM, backlog and fleet log. This is not a complete audit
of every session conversation.
+5 -3
View File
@@ -22,7 +22,7 @@ Ask these in your own chat. Offer the default so a one-word answer works. Pass t
## Codex tool approval
Codex may pause on fleet mail tools such as `tell` and `mailbox_send`. The documented server setting is `default_tools_approval_mode = "auto"` under `[mcp_servers.agent-fleet]` in `~/.codex/config.toml`. One tool is `[mcp_servers.agent-fleet.tools.<tool>]` with `approval_mode = "auto"`. The values are `auto`, `prompt`, `writes`, and `approve`. `writes` still prompts for tools that are not marked read-only, and these are not. Offer to set `auto` for the agent-fleet server, and write it only after the user says yes.
Codex may pause on fleet mail tools such as `tell` and `mailbox_send`. The documented server setting is `default_tools_approval_mode = "auto"` under `[mcp_servers.agent-fleet]` in `~/.codex/config.toml`. One tool is `[mcp_servers.agent-fleet.tools.<tool>]` with `approval_mode = "auto"`. The values are `auto`, `prompt`, `writes`, and `approve`. `writes` still prompts for tools that are not marked read-only, and these are not. Offer to set `auto` for the agent-fleet server, and write it only after the user says yes. A running Codex session does not see a change to `~/.codex/config.toml` until that session is reloaded.
Those keys are `mcp_servers.<id>.default_tools_approval_mode` and `mcp_servers.<id>.tools.<tool>.approval_mode` in the Codex config reference (https://developers.openai.com/codex/config-reference) and the MCP page (https://learn.chatgpt.com/docs/extend/mcp). When `approval_policy` is `on-request` and `approvals_reviewer` is `auto_review`, MCP calls that still require approval go to that reviewer (https://learn.chatgpt.com/docs/sandboxing/auto-review). The words "internal coordination messages" are not in those pages.
@@ -34,11 +34,13 @@ Bring these to the user, and do not approve them yourself: money, a push, a depl
## Status
When the user asks how things are going, answer in plain language: how many agents, which areas, what each is working on, what landed, and what needs the user. `fleet status` (MCP `fleet_status`) says, for each area, whether the tmux window is alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail means that agent has not read it. Looking at status nudges that pane once if the mail has been waiting. A second look does not nudge again. If status shows unread mail that has been waiting, or a recovery nudge fired, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows that were). Do not ask the user to open the log. Read `hear`, `fleet/LOG.md`, the landing queue, and `git log`. Offer a dev-server preview when that would help them see the work.
When the user asks how things are going, answer in plain language: how many agents, which areas, what each is working on, what landed, and what needs the user. `fleet status` (MCP `fleet_status`) says, for each area, whether the tmux window is alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail means that agent has not read it. Looking at status nudges that pane once if the mail has been waiting. A second look does not nudge again. If status shows unread mail that has been waiting, or a recovery nudge fired, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a later Enter submitted it, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows and the tmux socket). Do not ask the user to open the log. Read `hear`, `fleet/LOG.md`, the landing queue, and `git log`. Offer a dev-server preview when that would help them see the work.
## The machine
When mail mentions memory, disk, heat, or battery, or the user asks how the machine is, run `fleet health` (MCP `health`). Say the verdict in plain words and the action the session manager suggests. Name the area that is using the CPU or memory when that is the cause. Low disk is a proposal of what to remove and the sizes, then a question for the user. Do not delete, kill a process, or change a system setting yourself.
When mail mentions memory, disk, heat, or battery, or the user asks how the machine is, run `fleet health` (MCP `health`). Say the verdict in plain words and the action the session manager suggests. Name the area that is using the CPU or memory when that is the cause. Swap occupancy alone is not a tight verdict: Linux keeps swapped pages. It counts when available memory is also low, or when swap is near full. Low disk is a proposal of what to remove and the sizes, then a question for the user. Do not delete, kill a process, or change a system setting yourself.
The kit default CPU temperature is 90°C. If this machine runs hot below that, offer a lower `health.cpuTempC` in `fleet.config.json` (85 is a reasonable laptop figure, not a new kit default) and write it only after the user says yes. The next `fleet health` reads that file. A change to `~/.codex/config.toml` is different: Codex sessions need a reload before they see it.
The schedules include a resource check every 15 minutes. `fleet crontab` only prints the lines. Offer to install them, and install them only after the user says yes. Do not tell the user to edit crontab.
+2 -2
View File
@@ -8,7 +8,7 @@ The human says what they want in plain words. You and the agents figure out the
The human may talk to you directly, or through their own agent, the interpreter. Messages from `interpreter` are the user. A line in this window that says you have new fleet mail is only a wake. Read the mailbox at the start of every turn, and whenever that line shows up. Reading takes the messages out of unread. The mail is the message. Reply by mailbox (`mailbox send interpreter --from {{area.id}}`, or MCP `mailbox_send`) with your next action. Do that once.
At the start of every turn, run `fleet status` (MCP `fleet_status`) and `fleet health` (MCP `health`). Status shows each area's window alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail that has been sitting is nudged again once by that look. A second look does not nudge again. Reading your mailbox runs that same check. There is no ack protocol and no background checker. If that look shows unread mail that has been waiting, or it nudges a pane again, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows that were). Do not ask them to open the log. Health is the machine snapshot. The machine section says what you may change.
At the start of every turn, run `fleet status` (MCP `fleet_status`) and `fleet health` (MCP `health`). Status shows each area's window alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail that has been sitting is nudged again once by that look. A second look does not nudge again. Reading your mailbox runs that same check. There is no ack protocol and no background checker. If that look shows unread mail that has been waiting, or it nudges a pane again, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a later Enter submitted it, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows and the tmux socket). Do not ask them to open the log. Health is the machine snapshot. The machine section says what you may change.
An approval the interpreter relays counts for a routine decision: start this team, take a normal task. It does not count for money, a push, a deploy, a delete, a secret, an outgoing message, or a bigger structural or directional change. Those go to the real user. If you are unsure which it is, ask the interpreter to take it to the user. Do not ask the user to edit a file, attach to tmux, or run a command.
@@ -16,7 +16,7 @@ An approval the interpreter relays counts for a routine decision: start this tea
`fleet health` is one snapshot: CPU load and usage, memory and swap, disk free for the repo, the worktree root, the state dir, and tmp, CPU temperature and battery when the machine exposes them, and the CPU and memory of each area's process tree. A missing sensor says unavailable. The command changes nothing.
Run it at the start of every turn, and again before you staff another area. The `health` block in `fleet.config.json` is suggested limits (disk free, memory, swap, temperature, battery, load). A missing block uses the built-in defaults. They are guidance. You decide.
Run it at the start of every turn, and again before you staff another area. The `health` block in `fleet.config.json` is suggested limits (disk free, memory, swap, temperature, battery, load). A missing block uses the built-in defaults. They are guidance. You decide. Swap occupancy alone is not tight: Linux keeps swapped pages. Swap counts when available memory is also low, or when swap is near full (default 90%). The CPU temperature default stays 90°C. A lower `cpuTempC` in that block is a per-repo choice. The interpreter offers one and writes it only after the user says yes. Do not change it yourself.
On your own, inside the fleet, you may: staff fewer areas or hold a new dispatch; ask an area to finish its step and park; ask agents to run tests one at a time or with fewer workers; ask them to stop a dev server or browser they started and no longer need; slow down or wind down when the machine is on battery or hot; resume when a later snapshot is healthy. One hot reading is a warning. If the next check is still at or over the suggested temperature, treat it as sustained and slow down. Name the area whose process tree is using the CPU or memory.
+1 -1
View File
@@ -1,6 +1,6 @@
You are a scheduled headless job of the {{project}} dispatcher, started by tools/fleet/bin/sched.sh in the dispatcher's worktree on {{date}}. You have no memory of earlier runs: docs/agents/PROGRAM.md, the backlog, `node tools/fleet/bin/doctor.mjs` and docs/agents/WIND-DOWN.md are your context. Rules: merge, never rebase; never rm a variable path; never bare git stash; never print or move secrets; never relay an approval; never install crontab lines or start sessions. Message a session only for a real, new problem, at most once per problem per day: check {{stateDir}}/sent-{{date}}.txt and append what you send. Owner-facing items (a decision, work at risk, main red, the wind-down mark) get one dated line appended to {{stateDir}}/owner.md. Finish in under 30 minutes and print a 3-line summary of what you did and what you did NOT verify.
TASK: machine resources. Run `node tools/fleet/bin/fleet.mjs health`. This job is the check for when no one is in a turn. The `health` numbers in fleet.config.json are suggested limits, not orders. A missing sensor is unavailable and is not itself a problem.
TASK: machine resources. Run `node tools/fleet/bin/fleet.mjs health`. This job is the check for when no one is in a turn. The `health` numbers in fleet.config.json are suggested limits, not orders. A missing sensor is unavailable and is not itself a problem. Swap occupancy alone is not a crossed limit. It counts when available memory is also low, or when swap is near full.
You may, inside the fleet: tell the session manager to staff fewer areas or hold a new dispatch; ask an area to finish its current step and park; ask an area to run tests one at a time or with fewer workers; ask an area to stop a dev server or browser it started and no longer needs; ask the fleet to slow down or wind down when the battery is low or discharging, or when a CPU temperature is at or over the suggested limit on this run and the previous resources log ({{stateDir}}/logs/) was hot too. When a later snapshot is healthy, tell them they can resume. Name the area whose process tree is using the CPU or memory.
+1 -1
View File
@@ -1,3 +1,3 @@
You are a scheduled headless job of the {{project}} dispatcher, started by tools/fleet/bin/sched.sh in the dispatcher's worktree on {{date}}. You have no memory of earlier runs: docs/agents/PROGRAM.md, the backlog, `node tools/fleet/bin/doctor.mjs` and docs/agents/WIND-DOWN.md are your context. Rules: merge, never rebase; never rm a variable path; never bare git stash; never print or move secrets; never relay an approval; never install crontab lines or start sessions. Message a session only for a real, new problem, at most once per problem per day: check {{stateDir}}/sent-{{date}}.txt and append what you send. Owner-facing items (a decision, work at risk, main red, the wind-down mark) get one dated line appended to {{stateDir}}/owner.md. Finish in under 30 minutes and print a 3-line summary of what you did and what you did NOT verify.
TASK: restart after the reset. Remove {{stateDir}}/PAUSED if it exists (it is a single known file; delete it by that literal path). Append one line to owner.md that the fleet may restart. If the dispatcher session is reachable, message it: "Restart: read docs/agents/RESUME-BRIEF.md and resume". Do NOT start sessions yourself unless fleet.config.json says the owner approved automatic restarts.
TASK: reset checkpoint for an already active, unpaused fleet. Read docs/agents/RESUME-BRIEF.md and report the next eligible work to the session manager. Never remove PAUSED or start sessions: down and pause are explicit owner controls, including for this schedule. The owner must unpause and bring the fleet up before scheduled work can resume.
+3
View File
@@ -281,5 +281,8 @@
"producer": false,
"reviewer": false,
"usageGovernor": false
},
"health": {
"cpuTempC": 85
}
}
+4 -4
View File
@@ -6,15 +6,15 @@
"docs/agents/prompts/dispatch.md": "f7b7fe719e20a5f93794b4142690778f1231c5c9c01c3244c3e9adc2d84b995c",
"docs/agents/schedules/checkin.md": "5c26b3a903ac3473dc3cc65a615b3db59bff1a0ab4ddfe7d82604a748440cff9",
"docs/agents/schedules/hygiene.md": "08e8adb6930ef425cf71936479f7ee125300bb2ad337f00a4e6c68789a1b5918",
"docs/agents/schedules/restart.md": "1a18cb31927d4de0c51f1bf5134cbdf363f59af30dca31de519776494fa10d94",
"docs/agents/schedules/restart.md": "b567cfe7021747affe4b768c07b28dce8a0a763d9700932f253e932f6bc96568",
"docs/agents/schedules/summary.md": "7ecccd7ceec28b52e1ffcfcf38e7a3c8881880641c163d6da52e1c5de46e820f",
"docs/agents/schedules/triage.md": "0b1f52fd6a4bd14ec8be9f90e0504ed153bf2f96acd65c178ce9c3eef7a9253f",
"docs/agents/schedules/winddown.md": "36429467ce62e1f26a7eb6c2e6111ecd490c18bf691ee3fdb73c2fda71838d74",
"docs/agents/WIND-DOWN.md": "b27ae51b0b8e739e0fcca736e4cb44dfdd38b71fff1dd4261ff4df18d575df3a",
"docs/agents/STRIKES.md": "dd3047e02287207a1de96c01d5e9a1889c2c37cb75a5b3655de6e813df7f5bd9",
"docs/agents/prompts/interpreter.md": "776b16799a9516c108b19b22755796706aef3ee9ab98bc5d6ccc33542aae3f19",
"docs/agents/prompts/session-manager.md": "c5c7358c439e8593792bd1a14d8dd7edf59e7f5c3f0b1f72b78293910b7c8635",
"docs/agents/prompts/interpreter.md": "04d06ae3dc4910d1e5eea87f1497720e9a8648b9af7278e8265894b293ef7d7f",
"docs/agents/prompts/session-manager.md": "7acaf1e520714d766e1b1735a87d9f96f356d33762c03471e4ba6759df32b3b1",
".agents/skills/agent-fleet/SKILL.md": "25d5aeef4361b3eecff525f752d990acd2ce8988b59f790ba8363eece8e646c7",
"docs/agents/schedules/resources.md": "1e1236eea30cd2d73f68d2093339cfb9db8a97784b9af3e7ab53edf773541072"
"docs/agents/schedules/resources.md": "e0689943c23414d1023a09b46f9cd87b88d6cca6e9d4ec0a36f5112938e25f3e"
}
}
+16 -4
View File
@@ -5,12 +5,16 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { tmuxCommand } from './tmux.mjs';
export const HEALTH_DEFAULTS = {
diskFreePct: 10,
diskFreeGb: 5,
memoryAvailablePct: 15,
swapUsedPct: 50,
// Occupancy alone is not tight: Linux keeps swapped pages. swapUsedPct counts
// only when available memory is also low. swapFullPct counts on its own.
swapFullPct: 90,
cpuTempC: 90,
batteryPct: 20,
batteryMinutes: 30,
@@ -556,8 +560,9 @@ function listPanes(c, opt) {
if (opt.panes) return opt.panes;
const session = `fleet-${c?.project || 'fleet'}`.replace(/[.:]/g, '-');
return (c?.areas || []).map((area) => {
const r = sh('tmux', ['list-panes', '-t', `${session}:${area.id}`, '-F', '#{pane_pid}'], 2000);
const pid = Number(String(r || '').trim().split(/\s+/)[0]);
const r = tmuxCommand(c, ['list-panes', '-t', `${session}:${area.id}`, '-F', '#{pane_pid}'], { timeout: 2000 });
const text = r.status === 0 ? r.stdout : null;
const pid = Number(String(text || '').trim().split(/\s+/)[0]);
return { id: area.id, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
});
}
@@ -600,8 +605,15 @@ function judge(snap, limits) {
}
if (mem?.swapTotalBytes > 0 && mem.swapUsedBytes != null) {
const pct = (100 * mem.swapUsedBytes) / mem.swapTotalBytes;
if (pct >= limits.swapUsedPct) {
crossed.push({ id: 'swap', words: `swap ${Math.round(pct)}% used, at or over ${limits.swapUsedPct}%` });
const availablePct = mem.totalBytes > 0 && mem.availableBytes != null ? (100 * mem.availableBytes) / mem.totalBytes : null;
const memoryLow = availablePct != null && availablePct < limits.memoryAvailablePct;
const nearFull = pct >= limits.swapFullPct;
// Occupancy alone is normal on Linux. Count it when memory is also low, or when swap is near full.
if (nearFull || (memoryLow && pct >= limits.swapUsedPct)) {
const words = nearFull
? `swap ${Math.round(pct)}% used, near full (at or over ${limits.swapFullPct}%)`
: `swap ${Math.round(pct)}% used while available memory is under ${limits.memoryAvailablePct}%`;
crossed.push({ id: 'swap', words });
}
}
const seen = new Set();
+77 -39
View File
@@ -2,10 +2,11 @@
// both call these. Do not copy this logic into a plugin or a second server.
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { KIT, area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs';
import { appendWakeLog, priorWakes } from './wake-log.mjs';
import { tmuxCommand, tmuxSocketFacts } from './tmux.mjs';
const STATUSES = ['open', 'in-progress', 'blocked', 'done', 'deferred'];
const SPECIAL = ['owner', 'scheduler', 'interpreter'];
@@ -232,32 +233,32 @@ function tmuxTarget(c, areaId) {
return `fleet-${c.project}`.replace(/[.:]/g, '-') + `:${areaId}`;
}
function tmuxText(args) {
return spawnSync('tmux', args, { encoding: 'utf8' });
function tmuxText(c, args) {
return tmuxCommand(c, args);
}
function capturePane(target) {
const r = tmuxText(['capture-pane', '-p', '-t', target]);
function capturePane(c, target) {
const r = tmuxText(c, ['capture-pane', '-p', '-t', target]);
return r.status === 0 ? r.stdout : '';
}
function cursorRow(target, pane) {
const info = tmuxText(['display-message', '-p', '-t', target, '#{cursor_y}']);
function cursorRow(c, target, pane) {
const info = tmuxText(c, ['display-message', '-p', '-t', target, '#{cursor_y}']);
const y = Number(String(info.stdout || '').trim());
const rows = String(pane).split('\n');
if (!Number.isInteger(y) || y < 0 || y >= rows.length) return rows[rows.length - 1] || '';
return rows[y] || '';
}
function stillInInput(target, line) {
return cursorRow(target, capturePane(target)).includes(line);
function stillInInput(c, target, line) {
return cursorRow(c, target, capturePane(c, target)).includes(line);
}
function inspectPane(target) {
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { found: false, dead: false, interrupt: false, idleSec: null, pane: '' };
const info = tmuxText(['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
function inspectPane(c, target) {
if (tmuxText(c, ['list-panes', '-t', target]).status !== 0) return { found: false, dead: false, interrupt: false, idleSec: null, pane: '' };
const info = tmuxText(c, ['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
const match = String(info.stdout || '').trim().match(/^([01])\s+(\d+)$/);
const pane = capturePane(target);
const pane = capturePane(c, target);
let idleSec = null;
let dead = false;
if (match) {
@@ -279,13 +280,14 @@ function snapBefore(before) {
/** Window names in the fleet session, so a miss says what was there. */
function sessionWindows(c) {
const session = `fleet-${c.project}`.replace(/[.:]/g, '-');
const has = tmuxText(['has-session', '-t', session]);
const listed = tmuxText(['list-windows', '-t', session, '-F', '#{window_index} #{window_name}']);
const has = tmuxText(c, ['has-session', '-t', session]);
const listed = tmuxText(c, ['list-windows', '-t', session, '-F', '#{window_index} #{window_name}']);
return {
session,
sessionUp: has.status === 0,
windows: listed.status === 0 ? listed.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [],
listError: listed.status === 0 ? '' : String(listed.stderr || has.stderr || '').trim().slice(0, 200),
...tmuxSocketFacts(c),
};
}
@@ -314,7 +316,7 @@ function claimWakeLock(lock) {
}
}
function handDeferred(c, areaId, from, file) {
function handDeferred(c, areaId, from, file, { enterOnly = false } = {}) {
const lock = wakeLockPath(c, areaId);
if (!claimWakeLock(lock)) return { started: false };
const poll = Number(c.wakeWait?.pollMs) > 0 ? Number(c.wakeWait.pollMs) : WAKE_WAIT_POLL_MS;
@@ -327,12 +329,13 @@ function handDeferred(c, areaId, from, file) {
'--file', file || '',
'--poll-ms', String(poll),
'--max-ms', String(max),
...(enterOnly ? ['--enter-only'] : []),
], { detached: true, stdio: 'ignore' });
if (!child.pid) {
try { fs.unlinkSync(lock); } catch { /* already gone */ }
return { started: false };
}
fs.writeFileSync(lock, `${JSON.stringify({ pid: child.pid, area: areaId, file: file || null, from, t: new Date().toISOString() })}\n`);
fs.writeFileSync(lock, `${JSON.stringify({ pid: child.pid, area: areaId, file: file || null, from, enterOnly, t: new Date().toISOString() })}\n`);
child.unref();
return { started: true, pid: child.pid };
}
@@ -359,10 +362,10 @@ function submitWake(c, areaId, from, meta, before) {
};
const finish = (fields) => {
const { line: _line, reason, ...logged } = fields;
appendWakeLog(c, { ...record, ...logged, tail: paneTail(capturePane(target)) });
appendWakeLog(c, { ...record, ...logged, tail: paneTail(capturePane(c, target)) });
return { submitted: logged.outcome === 'submitted', reason, line };
};
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) {
if (tmuxText(c, ['send-keys', '-t', target, '-l', line]).status !== 0) {
return finish({ action: 'send-failed', enterAttempts: 0, enterDelays: [], outcome: 'send-failed', reason: 'send-failed', line });
}
const enterDelays = [];
@@ -370,14 +373,29 @@ function submitWake(c, areaId, from, meta, before) {
for (const delay of ENTER_DELAYS_MS) {
sleepMs(delay);
enterDelays.push(delay);
tmuxText(['send-keys', '-t', target, 'Enter']);
tmuxText(c, ['send-keys', '-t', target, 'Enter']);
enterAttempts += 1;
sleepMs(ENTER_SETTLE_MS);
if (!stillInInput(target, line)) {
if (!stillInInput(c, target, line)) {
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'submitted', reason: 'submitted', line });
}
}
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'swallowed', reason: 'not-submitted', line });
// The line is still in the composer. A waiter presses Enter later. Do not
// give up, and do not type the line a second time.
if (meta.handoff === false) {
finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'deferred-enter', reason: 'composer', line });
return { submitted: false, reason: 'not-submitted', still: true, line, enterAttempts, enterDelays };
}
const hand = handDeferred(c, areaId, from, meta.file || '', { enterOnly: true });
return finish({
action: 'typed',
outcome: hand.started ? 'deferred-enter' : 'waiter-running',
enterAttempts,
enterDelays,
waiterPid: hand.pid || null,
reason: 'composer',
line,
});
}
/**
@@ -387,7 +405,7 @@ function submitWake(c, areaId, from, meta, before) {
*/
export function nudgeMail(c, areaId, from, meta = {}) {
const target = tmuxTarget(c, areaId);
const before = inspectPane(target);
const before = inspectPane(c, target);
const record = {
t: new Date().toISOString(),
area: areaId,
@@ -398,7 +416,7 @@ export function nudgeMail(c, areaId, from, meta = {}) {
};
const finish = (fields) => {
const { line, reason, ...logged } = fields;
const pane = logged.action === 'no-window' ? '' : capturePane(target);
const pane = logged.action === 'no-window' ? '' : capturePane(c, target);
const where = logged.action === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
appendWakeLog(c, { ...record, ...logged, ...where, tail: paneTail(pane) });
return { submitted: logged.outcome === 'submitted', reason, ...(line ? { line } : {}) };
@@ -421,12 +439,15 @@ export function nudgeMail(c, areaId, from, meta = {}) {
}
/** Poll until the pane is idle, the mail is read, or the window closes. One process per area. */
export function runWakeWait(c, { area, from, file, pollMs, maxMs } = {}) {
export function runWakeWait(c, { area, from, file, pollMs, maxMs, enterOnly = false } = {}) {
if (!area) die('wake-wait needs --area');
const poll = Number(pollMs) > 0 ? Number(pollMs) : WAKE_WAIT_POLL_MS;
const max = Number(maxMs) > 0 ? Number(maxMs) : WAKE_WAIT_MAX_MS;
const target = tmuxTarget(c, area);
const line = nudgeLine(from);
const started = Date.now();
let laterEnters = 0;
let mode = enterOnly ? 'enter' : 'wait';
const release = () => releaseWakeLock(c, area);
const log = (fields) => {
const where = fields.outcome === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
@@ -438,7 +459,7 @@ export function runWakeWait(c, { area, from, file, pollMs, maxMs } = {}) {
trigger: 'deferred',
...fields,
...where,
tail: fields.outcome === 'no-window' ? '' : paneTail(capturePane(target)),
tail: fields.outcome === 'no-window' ? '' : paneTail(capturePane(c, target)),
});
release();
};
@@ -448,24 +469,41 @@ export function runWakeWait(c, { area, from, file, pollMs, maxMs } = {}) {
if (left <= 0) break;
sleepMs(Math.min(poll, left));
if (file && !unread()) {
return log({ action: 'already-read', outcome: 'already-read', enterAttempts: 0, enterDelays: [], before: { found: true, dead: false, interrupt: false, idleSec: null } });
return log({ action: 'already-read', outcome: 'already-read', enterAttempts: laterEnters, enterDelays: [], before: { found: true, dead: false, interrupt: false, idleSec: null } });
}
const before = inspectPane(target);
const before = inspectPane(c, target);
if (!before.found) {
return log({ action: 'no-window', outcome: 'no-window', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
return log({ action: 'no-window', outcome: 'no-window', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
if (before.dead) {
return log({ action: 'no-window', outcome: 'dead', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
return log({ action: 'no-window', outcome: 'dead', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
if (before.interrupt) continue;
try {
return submitWake(c, area, from, { trigger: 'deferred', file: file || null }, before);
} finally {
release();
if (mode === 'wait') {
const wake = submitWake(c, area, from, { trigger: 'deferred', file: file || null, handoff: false }, before);
if (wake.submitted || wake.reason === 'send-failed') {
release();
return wake;
}
mode = 'enter';
continue;
}
if (!stillInInput(c, target, line)) {
return log({ action: 'later-enter', outcome: 'submitted', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
tmuxText(c, ['send-keys', '-t', target, 'Enter']);
laterEnters += 1;
sleepMs(ENTER_SETTLE_MS);
if (!stillInInput(c, target, line)) {
return log({ action: 'later-enter', outcome: 'submitted', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
}
const before = inspectPane(target);
return log({ action: 'gave-up', outcome: 'timeout', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
const before = inspectPane(c, target);
const stuck = before.found && stillInInput(c, target, line);
if (mode === 'enter' && stuck) {
return log({ action: 'later-enter', outcome: 'swallowed', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
return log({ action: 'gave-up', outcome: 'timeout', enterAttempts: laterEnters, enterDelays: [], before: snapBefore(before) });
}
function unreadFiles(c, id) {
@@ -477,13 +515,13 @@ function unreadFiles(c, id) {
function paneView(c, areaId, idleAfterMs) {
const target = tmuxTarget(c, areaId);
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { window: 'dead', activity: 'unknown' };
const info = tmuxText(['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
if (tmuxText(c, ['list-panes', '-t', target]).status !== 0) return { window: 'dead', activity: 'unknown' };
const info = tmuxText(c, ['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
const match = String(info.stdout || '').trim().match(/^([01])\s+(\d+)$/);
if (info.status !== 0 || !match) return { window: 'dead', activity: 'unknown' };
const [, dead, stamp] = match;
if (dead === '1') return { window: 'dead', activity: 'unknown' };
const pane = capturePane(target);
const pane = capturePane(c, target);
const busy = BUSY_RE.test(pane);
const ageMs = Date.now() - Number(stamp) * 1000;
const activity = busy || ageMs < idleAfterMs ? 'working' : 'idle';
+43
View File
@@ -0,0 +1,43 @@
// Scheduled jobs may run only while an interactive fleet is alive and unpaused.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { pidAlive, killProcessGroup, runProcessGroup } from '../lib.mjs';
export function inactiveReason(c) {
if (fs.existsSync(path.join(c.stateDir, 'PAUSED'))) return 'fleet paused';
for (const area of c.areas) {
try {
const run = JSON.parse(fs.readFileSync(path.join(c.stateDir, 'runs', `${area.id}.json`), 'utf8'));
if (run.status === 'running' && Number.isInteger(run.pid) && run.pid > 1 && pidAlive(run.pid)) return null;
} catch { /* Missing or invalid state is not permission to launch. */ }
}
return 'fleet down';
}
export async function guardSchedule(c, job, { runner = fileURLToPath(new URL('../fleet.mjs', import.meta.url)), pollMs = 1000 } = {}) {
if (!c.schedules.some((entry) => entry.name === job)) throw new Error(`Unknown scheduled job: ${job}`);
const reason = inactiveReason(c);
if (reason) return { status: 0, reason };
let poll;
let force;
let stopped;
const result = await runProcessGroup({
cmd: process.execPath, args: [runner, 'run-job', job, ...(c.file ? ['--config', c.file] : [])], cwd: c.repo, env: process.env,
onSpawn(pid) {
poll = setInterval(() => {
const why = inactiveReason(c);
if (!why || stopped) return;
stopped = why;
// Only the process group this invocation created. Nested fleet runners
// receive TERM and forward it to their own agent process groups.
killProcessGroup(pid, 'SIGTERM');
force = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), 3000);
}, pollMs);
},
}).finally(() => {
clearInterval(poll);
clearTimeout(force);
});
return { status: stopped ? 0 : (result.status ?? 1), reason: stopped ?? null };
}
+142
View File
@@ -0,0 +1,142 @@
// The fleet session lives on one tmux server. A sender (Codex MCP, a sandbox,
// a shell) often has a different TMUX or TMUX_TMPDIR, so a bare `tmux` looks
// at the wrong server and reports the pane missing. fleet up records the
// socket. Every later command passes -S and ignores the sender's TMUX.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
export function fleetTmuxSession(c) {
return `fleet-${c?.project || 'fleet'}`.replace(/[.:]/g, '-');
}
export function tmuxSocketFile(c) {
return path.join(c.stateDir, 'tmux-socket');
}
function readSocketFile(c) {
if (!c?.stateDir) return '';
try {
const sock = fs.readFileSync(tmuxSocketFile(c), 'utf8').trim().split('\n')[0].trim();
return sock.startsWith('/') ? sock : '';
} catch {
return '';
}
}
function writeSocketFile(c, sock) {
if (!c?.stateDir || !sock || !sock.startsWith('/')) return;
fs.mkdirSync(c.stateDir, { recursive: true });
fs.writeFileSync(tmuxSocketFile(c), `${sock}\n`);
}
function cleanEnv(base, { keepTmux = false } = {}) {
const env = { ...base };
if (!keepTmux) delete env.TMUX;
return env;
}
function hasSession(session, socket, env) {
const args = [...(socket ? ['-S', socket] : []), 'has-session', '-t', session];
const r = spawnSync('tmux', args, { encoding: 'utf8', env });
return r.status === 0;
}
function socketPathOf(session, socket, env) {
const args = [...(socket ? ['-S', socket] : []), 'display-message', '-p', '-t', session, '#{socket_path}'];
const r = spawnSync('tmux', args, { encoding: 'utf8', env });
const sock = String(r.stdout || '').trim();
return r.status === 0 && sock.startsWith('/') ? sock : '';
}
function socketDirs() {
const uid = typeof process.getuid === 'function' ? process.getuid() : '';
const roots = [process.env.TMUX_TMPDIR, process.env.XDG_RUNTIME_DIR, '/tmp'];
return [...new Set(roots.filter(Boolean).map((root) => path.join(root, `tmux-${uid}`)))];
}
function listSockets() {
const found = [];
for (const dir of socketDirs()) {
let names = [];
try { names = fs.readdirSync(dir); } catch { continue; }
for (const name of names) {
const full = path.join(dir, name);
try {
if (fs.statSync(full).isSocket()) found.push(full);
} catch { /* gone */ }
}
}
return found;
}
/** Socket that actually has this fleet session, or '' if none is visible. */
function findSocket(session) {
const bare = cleanEnv(process.env);
delete bare.TMUX_TMPDIR;
if (hasSession(session, '', bare)) {
const pathOf = socketPathOf(session, '', bare);
if (pathOf) return pathOf;
}
const fromEnv = String(process.env.TMUX || '').split(',')[0];
if (fromEnv.startsWith('/') && hasSession(session, fromEnv, cleanEnv(process.env))) return fromEnv;
for (const sock of listSockets()) {
if (sock === fromEnv) continue;
if (hasSession(session, sock, cleanEnv(process.env))) return sock;
}
return '';
}
export function resolveTmuxSocket(c) {
if (!c) return '';
if (c._tmuxSocket) return c._tmuxSocket;
const session = fleetTmuxSession(c);
const saved = readSocketFile(c);
if (saved && hasSession(session, saved, cleanEnv(process.env))) {
c._tmuxSocket = saved;
return saved;
}
const found = findSocket(session);
if (found) {
writeSocketFile(c, found);
c._tmuxSocket = found;
return found;
}
// Keep a recorded path even when the session is not on it, so the miss
// log names the server we actually queried instead of the sender's TMUX.
if (saved) {
c._tmuxSocket = saved;
return saved;
}
return '';
}
/** After fleet up creates or reuses the session, store the server it used. */
export function rememberTmuxServer(c) {
const session = fleetTmuxSession(c);
const known = c?._tmuxSocket || readSocketFile(c);
const env = cleanEnv(process.env, { keepTmux: !known });
const sock = socketPathOf(session, known, env);
if (!sock) return '';
writeSocketFile(c, sock);
c._tmuxSocket = sock;
return sock;
}
export function tmuxSocketFacts(c) {
const saved = c?._tmuxSocket || readSocketFile(c);
return {
socket: saved || null,
socketExists: saved ? fs.existsSync(saved) : false,
tmuxEnv: process.env.TMUX || null,
tmuxTmpdir: process.env.TMUX_TMPDIR || null,
};
}
export function tmuxCommand(c, args, opts = {}) {
const socket = resolveTmuxSocket(c);
const argv = socket ? ['-S', socket, ...args] : args;
const env = cleanEnv({ ...process.env, ...(opts.env || {}) }, { keepTmux: !socket });
const { env: _env, ...rest } = opts;
return spawnSync('tmux', argv, { encoding: 'utf8', ...rest, env });
}
+8 -1
View File
@@ -40,6 +40,7 @@ export function wakeFlag(entry) {
const last = Array.isArray(entry.prior) ? entry.prior[entry.prior.length - 1] : null;
const age = entry.ageSec ?? '?';
if (!last) return `still unread after ${age}s; no earlier wake`;
if (last.outcome === 'deferred-enter' || (last.action === 'later-enter' && last.outcome !== 'submitted')) return `woke at ${last.t} (${last.trigger}), wake line still in the composer, still unread after ${age}s`;
if (last.outcome === 'swallowed') return `woke at ${last.t} (${last.trigger}), wake line still in the composer, still unread after ${age}s`;
if (last.outcome === 'timeout') return `woke at ${last.t} (${last.trigger}), pane stayed busy until the waiter stopped, still unread after ${age}s`;
if (last.outcome === 'deferred' || last.action === 'deferred') return `woke at ${last.t} (${last.trigger}), handed to a waiter because the pane was busy, still unread after ${age}s`;
@@ -51,8 +52,14 @@ export function wakeFlag(entry) {
if (entry.action === 'no-window' || entry.outcome === 'no-window') {
const names = Array.isArray(entry.windows) && entry.windows.length ? entry.windows.join(', ') : 'none';
const looked = entry.lookup || entry.window || 'unknown window';
return `pane not found (looked up ${looked}; session windows: ${names})`;
const sock = entry.socket ? `socket ${entry.socket}${entry.socketExists === false ? ' missing' : ''}` : 'no recorded socket';
return `pane not found (looked up ${looked}; ${sock}; session windows: ${names})`;
}
if (entry.action === 'later-enter' && entry.outcome === 'submitted') {
return entry.enterAttempts ? 'a later Enter left the composer' : 'the wake line left the composer before the later Enter';
}
if (entry.action === 'later-enter' && entry.outcome === 'swallowed') return `a later Enter still left the wake line in the composer (${entry.enterAttempts} more)`;
if (entry.outcome === 'deferred-enter') return `wake line still in the composer after ${entry.enterAttempts} Enter; a later Enter is waiting`;
if (entry.outcome === 'waiter-running') return 'a waiter is already polling this pane';
if (entry.outcome === 'already-read') return 'mail was read before the pane went idle, so nothing was typed';
if (entry.outcome === 'timeout') return 'pane stayed busy until the waiter stopped';
+1
View File
@@ -11,5 +11,6 @@ if (runningAsMain(import.meta.url)) {
file: flag(process.argv, '--file') || '',
pollMs: Number(flag(process.argv, '--poll-ms')) || 0,
maxMs: Number(flag(process.argv, '--max-ms')) || 0,
enterOnly: process.argv.includes('--enter-only'),
});
}
+12 -6
View File
@@ -41,7 +41,8 @@
// fleet health [--json] one snapshot: CPU, memory, disk, temps, battery, per-area use
// fleet prompt <id> print the composed system prompt for an area
// fleet crontab PRINT the crontab lines for scheduled jobs (never installs)
// fleet run-job <name> one headless run of docs/agents/schedules/<name>.md (sched.sh calls this)
// fleet run-job <name> explicit manual headless one-shot; cron uses scheduled-job
// fleet scheduled-job <name> guarded cron entry: skips when down or paused
// fleet sched-env <name> shell vars for sched.sh
// fleet lander start|status start a drain-and-exit lander, or print its status
//
@@ -55,6 +56,8 @@ import { KIT, loadConfig, loadAdapter, area as getArea, dispatcher, sessionName,
import { commitWork, setupCommit, tellDispatch, hearInterpreter, fleetStatus } from './core/ops.mjs';
import { readWakeLog } from './core/wake-log.mjs';
import { fleetHealth, formatHealth } from './core/health.mjs';
import { guardSchedule } from './core/schedule-guard.mjs';
import { tmuxCommand, rememberTmuxServer } from './core/tmux.mjs';
import { fleetUpdate, recordInstalled, isTemplatePath } from './core/update.mjs';
const argv = process.argv.slice(2);
@@ -148,11 +151,12 @@ async function launch(a, { fresh, bg, dry, sessions }) {
const ad = await adapterFor(a.harness);
if (!ensureWorktree(a)) return true;
const windowNames = () => {
const listed = spawnSync('tmux', ['list-windows', '-t', tmuxSession(), '-F', '#{window_name}'], { encoding: 'utf8' });
const listed = tmuxCommand(c, ['list-windows', '-t', tmuxSession(), '-F', '#{window_name}']);
return listed.status === 0 ? listed.stdout.split('\n').filter(Boolean) : [];
};
const open = windowNames().includes(a.id);
if (open && !dry) {
rememberTmuxServer(c);
console.error(`warn: ${a.id} already has tmux window ${tmuxSession()}:${a.id}; skipping. \`fleet down --only ${a.id}\` closes it`);
return true;
}
@@ -198,8 +202,9 @@ async function launch(a, { fresh, bg, dry, sessions }) {
const sup = [process.execPath, process.argv[1], 'supervise', a.id, '--config', c.file].map(q).join(' ');
if (spawnSync('tmux', ['-V']).status === 0) {
const s = tmuxSession();
if (spawnSync('tmux', ['has-session', '-t', s]).status !== 0) spawnSync('tmux', ['new-session', '-d', '-s', s, '-n', 'shell']);
spawnSync('tmux', ['new-window', '-t', s, '-n', a.id, '-c', a.worktree, sup], { stdio: 'inherit' });
if (tmuxCommand(c, ['has-session', '-t', s]).status !== 0) tmuxCommand(c, ['new-session', '-d', '-s', s, '-n', 'shell']);
tmuxCommand(c, ['new-window', '-t', s, '-n', a.id, '-c', a.worktree, sup], { stdio: 'inherit' });
rememberTmuxServer(c);
console.log(`[${a.id}] tmux window ${s}:${a.id} budget=${budgetLabel}`);
} else {
console.log(`[${a.id}] no tmux: run this yourself\n ${sup}`);
@@ -275,10 +280,10 @@ async function down() {
}
const latest = readRun(c, a.id);
if (killed || latest?.status === 'running') writeRun(c, a.id, { ...latest, status: 'stopped', reason: 'down', endedAt: new Date().toISOString() });
if (!full) spawnSync('tmux', ['kill-window', '-t', `${tmuxSession()}:${a.id}`]);
if (!full) tmuxCommand(c, ['kill-window', '-t', `${tmuxSession()}:${a.id}`]);
}
if (full) {
spawnSync('tmux', ['kill-session', '-t', tmuxSession()]);
tmuxCommand(c, ['kill-session', '-t', tmuxSession()]);
const pid = landerPid(c);
if (stopPid(pid)) console.log(`lander stopped (${pid})`);
else console.log('lander not running');
@@ -640,6 +645,7 @@ function tool(name) {
const run = { up, down, status, supervise: () => supervise(argv[1]), worktrees, crontab,
prompt: async () => console.log(composePrompt(getArea(c, argv[1]), await adapterFor(getArea(c, argv[1]).harness))),
'scheduled-job': async () => { process.exitCode = (await guardSchedule(c, argv[1])).status; },
'run-job': () => runJob(argv[1]), 'sched-env': () => schedEnv(argv[1]), lander: () => lander(argv[1] ?? 'status'),
commit: commitCmd, 'setup-commit': setupCommitCmd, update: updateCmd, tell: tellCmd, hear: hearCmd, wakes: wakesCmd, health: healthCmd,
doctor: () => tool('doctor'), landq: () => tool('landq'), backlog: () => tool('backlog'),
+1 -1
View File
@@ -20,7 +20,7 @@ const INSTRUCTIONS = [
'mailbox_read at the start of a turn. Mail is always delivered; the prompt says when to act on it. Sandboxed agents commit with the commit tool (message plus paths, or all), not git commit. submit on that tool also queues the tip. Then go back to work. At your next natural checkpoint, land_status can report that submission. Waiting is optional. Never infer the lander or the land result from silence. landq_submit queues the committed tip (a dirty worktree warns and still queues HEAD) and starts a drain. Do not push main.',
'A scarce machine is a prompt convention: say so in .task and wait if a peer already has it. There is no slot lock.',
'Sessions, worktrees, and cron stay on the fleet CLI. This server does not start them.',
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. wake_log says whether the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there. The missing-pane line names the windows that were. Tell the user that case. Do not ask them to open the log. health is one snapshot of CPU, memory, swap, disk, temperature, battery, and each area process tree. Missing sensors are unavailable. The config health block is guidance. Say the verdict in plain words. Do not scrape the tmux pane.',
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. wake_log says whether the wake line was still in the composer, a later Enter submitted it, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there. The missing-pane line names the windows and the tmux socket. Tell the user that case. Do not ask them to open the log. health is one snapshot of CPU, memory, swap, disk, temperature, battery, and each area process tree. Missing sensors are unavailable. Swap occupancy alone is not tight. The config health block is guidance. A lower cpuTempC is a per-repo edit, offered to the user, not a new default. A change to ~/.codex/config.toml needs a Codex reload. Say the verdict in plain words. Do not scrape the tmux pane.',
].join(' ');
const cwdProp = { type: 'string', description: 'Directory to resolve the fleet from. Defaults to this server\'s cwd.' };
+5 -5
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# sched.sh <job> — the machine-level scheduler entry, called from the system crontab
# (or a systemd timer). Runs docs/agents/schedules/<job>.md as ONE short headless run
# in the dispatcher's worktree via `fleet run-job`, and:
# in the dispatcher's worktree via guarded `fleet scheduled-job`, and:
# - skips during quiet hours unless the job has `quietHours: "allowed"` in fleet.config.json
# - skips while <stateDir>/PAUSED exists (wind-down, or the owner), except jobs with
# `ignorePause: true` (restart)
# - skips while <stateDir>/PAUSED exists, including the restart job
# - scheduled-job requires a live interactive fleet and stops its own child on down/pause
# - flock -n: if the previous run of this job is still going, skip instead of piling up
# - timeout: the job's timeoutMin (default 40). `fleet run-job` applies the same
# limit and kills the child process group; this `timeout` is the backstop
@@ -25,11 +25,11 @@ if [ "$QUIET_OK" != 1 ]; then
echo "$(date -Is) quiet hours, skipped" >> "$LOG"; exit 0
fi
fi
if [ -f "$STATE/PAUSED" ] && [ "$ALWAYS" != 1 ]; then
if [ -f "$STATE/PAUSED" ]; then
echo "$(date -Is) fleet paused ($(head -c 200 "$STATE/PAUSED")), skipped" >> "$LOG"; exit 0
fi
{
echo "=== $(date -Is) $job start"
flock -n "$STATE/sched-$job.lock" timeout "${TIMEOUT_MIN}m" node "$HERE/fleet.mjs" run-job "$job" < /dev/null
flock -n "$STATE/sched-$job.lock" timeout "${TIMEOUT_MIN}m" node "$HERE/fleet.mjs" scheduled-job "$job" < /dev/null
echo "=== $(date -Is) $job exit $?"
} >> "$LOG" 2>&1