@hwip/cli 0.1.10 → 0.1.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/README.md +18 -10
- package/dist/device-presence.js +5 -2
- package/dist/listener.js +6 -1
- package/dist/pane-fit.d.ts +6 -0
- package/dist/pane-fit.js +15 -1
- package/dist/schedule-command.d.ts +8 -1
- package/dist/schedule-command.js +132 -21
- package/dist/schedule-remote.d.ts +17 -5
- package/dist/schedule-remote.js +76 -7
- package/dist/schedule-runner.d.ts +25 -1
- package/dist/schedule-runner.js +186 -7
- package/dist/schedule.d.ts +38 -6
- package/dist/schedule.js +91 -11
- package/dist/session-registry.d.ts +33 -2
- package/dist/session-registry.js +52 -3
- package/dist/templates.d.ts +10 -1
- package/dist/templates.js +13 -4
- package/dist/tmux-liveness.d.ts +5 -1
- package/dist/tmux-liveness.js +10 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +2 -2
- package/dist/turn-recorder.js +10 -2
- package/package.json +1 -1
package/dist/schedule-remote.js
CHANGED
|
@@ -10,6 +10,7 @@ const node_path_1 = require("node:path");
|
|
|
10
10
|
const schedule_js_1 = require("./schedule.js");
|
|
11
11
|
const schedule_ready_js_1 = require("./schedule-ready.js");
|
|
12
12
|
const session_registry_js_1 = require("./session-registry.js");
|
|
13
|
+
const tmux_liveness_js_1 = require("./tmux-liveness.js");
|
|
13
14
|
const wrapper_js_1 = require("./wrapper.js");
|
|
14
15
|
/** The longest prompt the list carries; the full text is in the file on this machine. */
|
|
15
16
|
exports.LIST_PROMPT_CHARS = 500;
|
|
@@ -37,9 +38,33 @@ const isoOrNull = (ms) => {
|
|
|
37
38
|
const date = new Date(ms);
|
|
38
39
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
39
40
|
};
|
|
40
|
-
/**
|
|
41
|
-
const
|
|
41
|
+
/** tmux as `liveSessionOfRun` asks it, each question answered once: one list answer asks for the names once, and each name once. */
|
|
42
|
+
const askingOnce = (tmux) => {
|
|
43
|
+
let names;
|
|
44
|
+
const runs = new Map();
|
|
45
|
+
return {
|
|
46
|
+
names: () => (names === undefined ? (names = tmux.names()) : names),
|
|
47
|
+
runIdOf: (name) => {
|
|
48
|
+
if (!runs.has(name))
|
|
49
|
+
runs.set(name, tmux.runIdOf(name));
|
|
50
|
+
return runs.get(name);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** What the list says about a session target's run: only `ended` is news (the app shows "session ended"). */
|
|
55
|
+
const targetFields = (schedule, now, tmux) => {
|
|
56
|
+
if (schedule.target !== 'session' || schedule.runId === undefined)
|
|
57
|
+
return {};
|
|
58
|
+
const where = (0, session_registry_js_1.liveSessionOfRun)(schedule.runId, schedule.session, tmux, now, Date.parse(schedule.createdAt));
|
|
59
|
+
return { target: 'session', runId: schedule.runId, ...(where === 'ended' ? { sessionEnded: true } : {}) };
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The schedules on this machine for the app, oldest first, prompts cut to `promptChars`. Null when the directory could
|
|
63
|
+
* not be listed or holds more than a machine may: an empty list would then be a lie.
|
|
64
|
+
*/
|
|
65
|
+
const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS, tmux = tmux_liveness_js_1.liveTmuxRuns) => {
|
|
42
66
|
const read = (0, schedule_js_1.listSchedules)();
|
|
67
|
+
const asked = askingOnce(tmux);
|
|
43
68
|
if (read.incomplete)
|
|
44
69
|
return null;
|
|
45
70
|
const state = (0, schedule_js_1.readScheduleState)().schedules;
|
|
@@ -57,6 +82,7 @@ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
|
|
|
57
82
|
maxRuntimeMin: schedule.maxRuntimeMin,
|
|
58
83
|
nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule))),
|
|
59
84
|
...(last && lastAt ? { last: { outcome: last.outcome, at: lastAt } } : {}),
|
|
85
|
+
...targetFields(schedule, now, asked),
|
|
60
86
|
};
|
|
61
87
|
});
|
|
62
88
|
return { schedules, unreadable: read.unreadable };
|
|
@@ -67,9 +93,10 @@ exports.phoneScheduleList = phoneScheduleList;
|
|
|
67
93
|
* cannot hand an older sealed list back as the answer to a newer request. Prompts are cut shorter, step by
|
|
68
94
|
* step, until the answer fits a frame; it is an error only when even the list without prompts does not.
|
|
69
95
|
*/
|
|
70
|
-
const scheduleAnswer = (now, requestId, at) => {
|
|
96
|
+
const scheduleAnswer = (now, requestId, at, tmux = tmux_liveness_js_1.liveTmuxRuns) => {
|
|
97
|
+
const once = askingOnce(tmux);
|
|
71
98
|
for (const promptChars of PROMPT_CHARS_STEPS) {
|
|
72
|
-
const listing = (0, exports.phoneScheduleList)(now, promptChars);
|
|
99
|
+
const listing = (0, exports.phoneScheduleList)(now, promptChars, once);
|
|
73
100
|
if (!listing)
|
|
74
101
|
return { ok: false, error: 'read_failed' };
|
|
75
102
|
const { schedules, unreadable } = listing;
|
|
@@ -85,10 +112,12 @@ exports.livePhoneScheduleDeps = {
|
|
|
85
112
|
recall: (session, agentKind) => (agentKind === undefined ? (0, session_registry_js_1.recallSession)(session) : (0, session_registry_js_1.recallRun)(session, agentKind)),
|
|
86
113
|
directoryExists: session_registry_js_1.sessionDirectoryExists,
|
|
87
114
|
canPrompt: schedule_ready_js_1.canPromptKind,
|
|
115
|
+
currentRun: tmux_liveness_js_1.liveRunOf,
|
|
116
|
+
recallRunOf: (session, runId, now) => (0, session_registry_js_1.recallSession)(session, now, runId) ?? ((0, session_registry_js_1.registryUnreadable)() ? 'unreadable' : null),
|
|
88
117
|
};
|
|
89
118
|
const refuse = (reason, why) => ({ ok: false, reason, why });
|
|
90
119
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
91
|
-
const CREATE_KEYS = new Set(['op', 'when', 'prompt', 'agentKind', 'maxRuntimeMin']);
|
|
120
|
+
const CREATE_KEYS = new Set(['op', 'when', 'prompt', 'agentKind', 'maxRuntimeMin', 'target']);
|
|
92
121
|
/** Stands in for the run time while the rest of a request is checked; the shortest `validateSchedule` takes. */
|
|
93
122
|
const SHORTEST_RUN_MIN = 5;
|
|
94
123
|
/** The fields every schedule made from a phone request has, whatever the request says: all but `id` come from this machine's record. */
|
|
@@ -102,11 +131,50 @@ const fromRecord = (entry, id, now) => ({
|
|
|
102
131
|
label: `sched-${id}`,
|
|
103
132
|
session: `${(0, wrapper_js_1.tmuxSessionName)((0, wrapper_js_1.safeBasename)(entry.cwd))}-sched-${id}`,
|
|
104
133
|
});
|
|
134
|
+
/**
|
|
135
|
+
* A create with `target: 'session'`: the schedule types into the addressed session instead of starting one. The run is
|
|
136
|
+
* the one the session holds now — this machine's word, not the phone's — and the agent and directory are that run's
|
|
137
|
+
* record. It has no runtime cap: a run time in the request (the apps always send one) is ignored.
|
|
138
|
+
*/
|
|
139
|
+
const createIntoSession = (request, session, deps) => {
|
|
140
|
+
const { when, prompt, agentKind } = request;
|
|
141
|
+
if (agentKind !== undefined)
|
|
142
|
+
return refuse('schedule_invalid', 'a create into a session takes the agent the session runs');
|
|
143
|
+
const runId = deps.currentRun(session);
|
|
144
|
+
if (runId === undefined)
|
|
145
|
+
return refuse('schedule_failed', 'tmux did not give the run of that session');
|
|
146
|
+
if (runId === null)
|
|
147
|
+
return refuse('unknown_session', 'the session is not running');
|
|
148
|
+
const now = deps.now();
|
|
149
|
+
const entry = deps.recallRunOf(session, runId, now);
|
|
150
|
+
if (entry === 'unreadable')
|
|
151
|
+
return refuse('schedule_failed', 'the record of sessions would not read');
|
|
152
|
+
if (!entry)
|
|
153
|
+
return refuse('unknown_session', 'no record of the run that session holds');
|
|
154
|
+
if (!deps.canPrompt(entry.agentKind))
|
|
155
|
+
return refuse('schedule_invalid', `${entry.agentKind} cannot take a scheduled prompt`);
|
|
156
|
+
const checked = (0, schedule_js_1.validateSchedule)({
|
|
157
|
+
v: 1, id: 'aaaaaa', createdAt: new Date(now).toISOString(), source: 'phone', agentKind: entry.agentKind, cwd: entry.cwd,
|
|
158
|
+
label: 'sched-aaaaaa', session, target: 'session', runId, when, prompt, maxRuntimeMin: SHORTEST_RUN_MIN,
|
|
159
|
+
});
|
|
160
|
+
if (!checked.ok)
|
|
161
|
+
return refuse('schedule_invalid', checked.error);
|
|
162
|
+
// Kept for the file's shape: a session target's run time bounds nothing.
|
|
163
|
+
const runtime = (0, schedule_js_1.defaultMaxRuntimeMin)(checked.value.when);
|
|
164
|
+
const added = (0, schedule_js_1.addSchedule)((id) => ({ ...checked.value, id, label: `sched-${id}`, maxRuntimeMin: runtime }));
|
|
165
|
+
if (added.ok)
|
|
166
|
+
return { ok: true };
|
|
167
|
+
return refuse(added.failure === 'limit' ? 'schedule_limit' : added.failure === 'io' ? 'schedule_failed' : 'schedule_invalid', added.error);
|
|
168
|
+
};
|
|
105
169
|
const create = (request, session, deps) => {
|
|
106
170
|
// Whatever else the request carries — a directory, arguments, a label — is refused, not ignored: it was meant to be used.
|
|
107
171
|
const extra = Object.keys(request).find((k) => !CREATE_KEYS.has(k));
|
|
108
172
|
if (extra !== undefined)
|
|
109
173
|
return refuse('schedule_invalid', `a create carries ${extra}`);
|
|
174
|
+
if (request.target !== undefined && request.target !== 'session')
|
|
175
|
+
return refuse('schedule_invalid', "target is 'session' or absent");
|
|
176
|
+
if (request.target === 'session')
|
|
177
|
+
return createIntoSession(request, session, deps);
|
|
110
178
|
const { when, prompt, agentKind, maxRuntimeMin } = request;
|
|
111
179
|
if (agentKind !== undefined && typeof agentKind !== 'string')
|
|
112
180
|
return refuse('schedule_invalid', 'agentKind is not text');
|
|
@@ -180,7 +248,8 @@ const update = (request, session, deps) => {
|
|
|
180
248
|
change.when = when;
|
|
181
249
|
if (prompt !== undefined)
|
|
182
250
|
change.prompt = prompt;
|
|
183
|
-
|
|
251
|
+
// The apps always send a run time; a session target has none to change, and keeps the one in its file.
|
|
252
|
+
if (maxRuntimeMin !== undefined && target.target !== 'session')
|
|
184
253
|
change.maxRuntimeMin = maxRuntimeMin;
|
|
185
254
|
const updated = (0, schedule_js_1.updateSchedule)(id, change, deps.now());
|
|
186
255
|
if (updated.ok)
|
|
@@ -190,7 +259,7 @@ const update = (request, session, deps) => {
|
|
|
190
259
|
};
|
|
191
260
|
/**
|
|
192
261
|
* Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
|
|
193
|
-
* recorded session `session
|
|
262
|
+
* recorded session `session` (or `{ op: 'create', target: 'session', when, prompt }` to type into it), `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
|
|
194
263
|
* schedule whose session that is. Nothing here
|
|
195
264
|
* types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
|
|
196
265
|
*/
|
|
@@ -5,14 +5,25 @@
|
|
|
5
5
|
*
|
|
6
6
|
* What a tick decides, per schedule: how many due times passed since its cursor, whether the newest is still
|
|
7
7
|
* worth running (`GRACE_MS`; older ones are `missed` and never run late), and whether the session may start.
|
|
8
|
+
* A `once` schedule that has run (or was missed), and whose session and report are done with, has its file removed.
|
|
9
|
+
*
|
|
10
|
+
* A session target (`target: 'session'`) starts nothing: at its time it waits (`delivering`) for the run it is bound to
|
|
11
|
+
* to be idle with nobody typing at its terminal, and types the prompt in. It never counts as running, has no runtime
|
|
12
|
+
* cap and is never asked to end — the session is the person's, not the schedule's.
|
|
8
13
|
* The cursor is written before anything is launched, so a listener killed in between skips that run instead of
|
|
9
14
|
* starting it twice.
|
|
10
15
|
*/
|
|
11
|
-
import
|
|
16
|
+
import type { RunWhereabouts } from './session-registry.js';
|
|
17
|
+
import { type RemoveResult, type Schedule, type ScheduleList, type ScheduleStateFile } from './schedule.js';
|
|
12
18
|
/** How often the listener ticks. */
|
|
13
19
|
export declare const TICK_MS = 30000;
|
|
14
20
|
/** A due time older than this when the tick sees it (the machine slept, the listener was down) is missed, not run. */
|
|
15
21
|
export declare const GRACE_MS = 120000;
|
|
22
|
+
/**
|
|
23
|
+
* The same for a `once` schedule: there is no next time to wait for, and a person who asked for "at 3 pm" still wants
|
|
24
|
+
* it at 3:20 after the laptop woke up.
|
|
25
|
+
*/
|
|
26
|
+
export declare const ONCE_GRACE_MS: number;
|
|
16
27
|
/** After a launch exits 0, how long before the session has to be there. */
|
|
17
28
|
export declare const START_CHECK_MS = 2000;
|
|
18
29
|
/** A launch that has not exited by then is given up on. */
|
|
@@ -30,6 +41,10 @@ export declare const PROMPT_POLL_MS = 5000;
|
|
|
30
41
|
* the session is idle, and a retry after a failed inject two polls later.
|
|
31
42
|
*/
|
|
32
43
|
export declare const PROMPT_WAIT_MS = 90000;
|
|
44
|
+
/** How long a session target's prompt waits for its session to be idle (and its terminal quiet) before it is `session_busy`. */
|
|
45
|
+
export declare const SESSION_WAIT_MS: number;
|
|
46
|
+
/** A terminal attached to the session that had a key pressed this recently is someone typing: the prompt is not typed after their words. */
|
|
47
|
+
export declare const SESSION_QUIET_MS: number;
|
|
33
48
|
/** Scheduled sessions running at once. */
|
|
34
49
|
export declare const MAX_RUNNING = 3;
|
|
35
50
|
export type LaunchResult = {
|
|
@@ -71,6 +86,15 @@ export interface ScheduleRunnerDeps {
|
|
|
71
86
|
endSession: (session: string) => void;
|
|
72
87
|
/** Tells the person (title, body). */
|
|
73
88
|
notify: (title: string, body: string) => Promise<NotifyOutcome>;
|
|
89
|
+
/** Where the run `runId` (last seen in session `lastName`, tied to at `boundAt`) goes on now (`liveSessionOfRun`). */
|
|
90
|
+
sessionOfRun: (runId: string, lastName: string, boundAt: number) => RunWhereabouts;
|
|
91
|
+
/**
|
|
92
|
+
* When a terminal attached to `session` was last used (ms since the epoch); null when none is attached, undefined
|
|
93
|
+
* when tmux could not say.
|
|
94
|
+
*/
|
|
95
|
+
lastClientActivity: (session: string) => number | null | undefined;
|
|
96
|
+
/** Deletes a schedule's file (`removeSchedule`): a `once` that is done. It may throw when the file cannot be removed. */
|
|
97
|
+
removeSchedule: (id: string) => RemoveResult;
|
|
74
98
|
/** The wall clock (default `Date.now`); a launch takes seconds, and the schedules after it are judged by the time it ends. */
|
|
75
99
|
clock?: () => number;
|
|
76
100
|
}
|
package/dist/schedule-runner.js
CHANGED
|
@@ -6,16 +6,26 @@
|
|
|
6
6
|
*
|
|
7
7
|
* What a tick decides, per schedule: how many due times passed since its cursor, whether the newest is still
|
|
8
8
|
* worth running (`GRACE_MS`; older ones are `missed` and never run late), and whether the session may start.
|
|
9
|
+
* A `once` schedule that has run (or was missed), and whose session and report are done with, has its file removed.
|
|
10
|
+
*
|
|
11
|
+
* A session target (`target: 'session'`) starts nothing: at its time it waits (`delivering`) for the run it is bound to
|
|
12
|
+
* to be idle with nobody typing at its terminal, and types the prompt in. It never counts as running, has no runtime
|
|
13
|
+
* cap and is never asked to end — the session is the person's, not the schedule's.
|
|
9
14
|
* The cursor is written before anything is launched, so a listener killed in between skips that run instead of
|
|
10
15
|
* starting it twice.
|
|
11
16
|
*/
|
|
12
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.createScheduleRunner = exports.launchArgs = exports.notifyFailureOutcome = exports.MAX_RUNNING = exports.PROMPT_WAIT_MS = exports.PROMPT_POLL_MS = exports.PUSH_RETRY_MAX_MS = exports.PUSH_RETRY_MS = exports.LAUNCH_WT_TIMEOUT_MS = exports.LAUNCH_TIMEOUT_MS = exports.START_CHECK_MS = exports.GRACE_MS = exports.TICK_MS = void 0;
|
|
18
|
+
exports.createScheduleRunner = exports.launchArgs = exports.notifyFailureOutcome = exports.MAX_RUNNING = exports.SESSION_QUIET_MS = exports.SESSION_WAIT_MS = exports.PROMPT_WAIT_MS = exports.PROMPT_POLL_MS = exports.PUSH_RETRY_MAX_MS = exports.PUSH_RETRY_MS = exports.LAUNCH_WT_TIMEOUT_MS = exports.LAUNCH_TIMEOUT_MS = exports.START_CHECK_MS = exports.ONCE_GRACE_MS = exports.GRACE_MS = exports.TICK_MS = void 0;
|
|
14
19
|
const schedule_js_1 = require("./schedule.js");
|
|
15
20
|
/** How often the listener ticks. */
|
|
16
21
|
exports.TICK_MS = 30_000;
|
|
17
22
|
/** A due time older than this when the tick sees it (the machine slept, the listener was down) is missed, not run. */
|
|
18
23
|
exports.GRACE_MS = 120_000;
|
|
24
|
+
/**
|
|
25
|
+
* The same for a `once` schedule: there is no next time to wait for, and a person who asked for "at 3 pm" still wants
|
|
26
|
+
* it at 3:20 after the laptop woke up.
|
|
27
|
+
*/
|
|
28
|
+
exports.ONCE_GRACE_MS = 30 * 60_000;
|
|
19
29
|
/** After a launch exits 0, how long before the session has to be there. */
|
|
20
30
|
exports.START_CHECK_MS = 2_000;
|
|
21
31
|
/** A launch that has not exited by then is given up on. */
|
|
@@ -33,6 +43,10 @@ exports.PROMPT_POLL_MS = 5_000;
|
|
|
33
43
|
* the session is idle, and a retry after a failed inject two polls later.
|
|
34
44
|
*/
|
|
35
45
|
exports.PROMPT_WAIT_MS = 90_000;
|
|
46
|
+
/** How long a session target's prompt waits for its session to be idle (and its terminal quiet) before it is `session_busy`. */
|
|
47
|
+
exports.SESSION_WAIT_MS = 30 * 60_000;
|
|
48
|
+
/** A terminal attached to the session that had a key pressed this recently is someone typing: the prompt is not typed after their words. */
|
|
49
|
+
exports.SESSION_QUIET_MS = 3 * 60_000;
|
|
36
50
|
/** A report that has waited this long for a push that would not go through is dropped, not sent as news. */
|
|
37
51
|
const PENDING_MAX_MS = 24 * 60 * 60 * 1000;
|
|
38
52
|
/** Scheduled sessions running at once. */
|
|
@@ -70,6 +84,8 @@ const REASON_TEXT = {
|
|
|
70
84
|
prompt_undelivered: '할 일 입력 못 함',
|
|
71
85
|
prompt_unsupported: '할 일 입력 불가(이 에이전트는 입력 준비를 판정할 규칙이 없음)',
|
|
72
86
|
capped: '실행 시간 초과',
|
|
87
|
+
session_busy: '세션이 계속 작업 중이라 건너뜀',
|
|
88
|
+
session_ended: '세션 종료됨',
|
|
73
89
|
};
|
|
74
90
|
/** The push title for each result: what kind of news it is. A report that mixes kinds gets the general one. */
|
|
75
91
|
const SKIPPED = '예약 건너뜀';
|
|
@@ -84,6 +100,8 @@ const TITLE_OF = {
|
|
|
84
100
|
prompt_undelivered: PROMPT_NOT_TYPED,
|
|
85
101
|
prompt_unsupported: PROMPT_NOT_TYPED,
|
|
86
102
|
capped: '예약 실행 시간 초과',
|
|
103
|
+
session_busy: SKIPPED,
|
|
104
|
+
session_ended: '예약 세션 종료됨',
|
|
87
105
|
};
|
|
88
106
|
const MIXED_TITLE = '예약 알림';
|
|
89
107
|
/** Adds `count` of `outcome` to what the person has not been told yet. */
|
|
@@ -125,6 +143,50 @@ const createScheduleRunner = (deps) => {
|
|
|
125
143
|
const launchedBefore = new Map();
|
|
126
144
|
// Sessions already asked to end for their runtime cap, so a state file that cannot be written does not ask again every tick.
|
|
127
145
|
const cappedAsked = new Set();
|
|
146
|
+
// `once` schedules whose file could not be removed, so the log says it once, not every tick.
|
|
147
|
+
const unremovable = new Set();
|
|
148
|
+
/**
|
|
149
|
+
* Removes the `once` schedules that are done: their time has been dealt with (run, failed or missed), their session is
|
|
150
|
+
* no longer running and their report has gone out. Until then the schedule stays, so its prompt, its runtime cap and
|
|
151
|
+
* its push are still looked after. Its cursor is past its time, so one that cannot be removed is never run again.
|
|
152
|
+
* A cursor can also pass the time without a run judging it (a damaged state file starts from now): that is told as
|
|
153
|
+
* missed first, never removed in silence.
|
|
154
|
+
*/
|
|
155
|
+
const removeFinished = (schedules, state, now) => {
|
|
156
|
+
let changed = false;
|
|
157
|
+
const live = schedules.filter((schedule) => {
|
|
158
|
+
const st = state[schedule.id];
|
|
159
|
+
const passed = schedule.when.kind === 'once' && st.cursor >= Date.parse(schedule.when.at);
|
|
160
|
+
if (!passed || st.running || st.delivering || st.pending)
|
|
161
|
+
return true;
|
|
162
|
+
if (!st.last) {
|
|
163
|
+
st.last = { outcome: 'missed', at: now };
|
|
164
|
+
addPending(st, 'missed', 1, now);
|
|
165
|
+
changed = true;
|
|
166
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): its time went by without a record; missed`);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
let removed;
|
|
170
|
+
try {
|
|
171
|
+
removed = deps.removeSchedule(schedule.id);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
removed = e instanceof Error ? e : new Error(String(e));
|
|
175
|
+
}
|
|
176
|
+
if (removed instanceof Error || removed === 'invalid') {
|
|
177
|
+
if (!unremovable.has(schedule.id))
|
|
178
|
+
deps.log(`! schedule ${schedule.id} (${schedule.label}): ran once, but could not remove it: ${removed instanceof Error ? removed.message : 'not a schedule id'}`);
|
|
179
|
+
unremovable.add(schedule.id);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
unremovable.delete(schedule.id);
|
|
183
|
+
delete state[schedule.id];
|
|
184
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): ran once, removed`);
|
|
185
|
+
changed = true;
|
|
186
|
+
return false;
|
|
187
|
+
});
|
|
188
|
+
return { live, changed };
|
|
189
|
+
};
|
|
128
190
|
const load = (tickNow) => {
|
|
129
191
|
const { schedules, unreadable, incomplete } = deps.listSchedules();
|
|
130
192
|
const file = deps.readState();
|
|
@@ -191,13 +253,41 @@ const createScheduleRunner = (deps) => {
|
|
|
191
253
|
sentReports = [];
|
|
192
254
|
pushing = false;
|
|
193
255
|
}
|
|
256
|
+
const { live, changed } = removeFinished(schedules, state, tickNow);
|
|
257
|
+
if (changed)
|
|
258
|
+
dirty = true;
|
|
194
259
|
const save = () => {
|
|
195
260
|
const saved = deps.writeState({ v: 1, schedules: state });
|
|
196
261
|
if (!saved)
|
|
197
262
|
deps.log('! schedules: could not write the state file');
|
|
198
263
|
return saved;
|
|
199
264
|
};
|
|
200
|
-
return { schedules, state, dirty, save };
|
|
265
|
+
return { schedules: live, state, dirty, save };
|
|
266
|
+
};
|
|
267
|
+
/** The end of a session target's time: what became of it, told unless it is the same as the last time (a streak). */
|
|
268
|
+
const settle = (st, outcome, now) => {
|
|
269
|
+
delete st.delivering;
|
|
270
|
+
// As with overlap and busy: a run that is over, or never idle, stays so for every time until someone acts.
|
|
271
|
+
const repeat = (outcome === 'session_busy' || outcome === 'session_ended') && st.last?.outcome === outcome;
|
|
272
|
+
st.last = { outcome, at: now };
|
|
273
|
+
if (outcome !== 'started' && !repeat)
|
|
274
|
+
addPending(st, outcome, 1, now);
|
|
275
|
+
};
|
|
276
|
+
/** A session target's time came: its wait begins, or (when it cannot) what that means. Returns what to log. */
|
|
277
|
+
const beginDelivery = (schedule, st, now) => {
|
|
278
|
+
if (!deps.canPrompt(schedule.agentKind)) {
|
|
279
|
+
settle(st, 'prompt_unsupported', now);
|
|
280
|
+
return `${schedule.agentKind} has no rule that says idle, so its prompt is not typed`;
|
|
281
|
+
}
|
|
282
|
+
// The last time's prompt is still waiting: this one is not queued behind it.
|
|
283
|
+
if (st.delivering) {
|
|
284
|
+
const { delivering } = st;
|
|
285
|
+
settle(st, 'session_busy', now);
|
|
286
|
+
st.delivering = delivering;
|
|
287
|
+
return 'session_busy (the last prompt is still waiting)';
|
|
288
|
+
}
|
|
289
|
+
st.delivering = { session: schedule.session, since: now };
|
|
290
|
+
return `waiting for ${schedule.session} to be idle`;
|
|
201
291
|
};
|
|
202
292
|
const run = async (tickNow) => {
|
|
203
293
|
const tickStart = clock();
|
|
@@ -212,7 +302,7 @@ const createScheduleRunner = (deps) => {
|
|
|
212
302
|
const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, (0, schedule_js_1.scheduleAnchor)(schedule));
|
|
213
303
|
if (due.count === 0 || due.latest === null)
|
|
214
304
|
continue;
|
|
215
|
-
const runs = now - due.latest <= exports.GRACE_MS;
|
|
305
|
+
const runs = now - due.latest <= (schedule.when.kind === 'once' ? exports.ONCE_GRACE_MS : exports.GRACE_MS);
|
|
216
306
|
const missed = runs ? due.count - 1 : due.count;
|
|
217
307
|
const before = structuredClone(st);
|
|
218
308
|
st.cursor = due.latest;
|
|
@@ -221,6 +311,9 @@ const createScheduleRunner = (deps) => {
|
|
|
221
311
|
st.last = { outcome: 'missed', at: now };
|
|
222
312
|
addPending(st, 'missed', missed, now);
|
|
223
313
|
}
|
|
314
|
+
const intoSession = runs && schedule.target === 'session';
|
|
315
|
+
// Saved with the cursor: a wait that is on disk is never begun twice, nor lost.
|
|
316
|
+
const begun = intoSession ? beginDelivery(schedule, st, now) : null;
|
|
224
317
|
if (!save()) {
|
|
225
318
|
state[schedule.id] = before;
|
|
226
319
|
continue;
|
|
@@ -228,7 +321,9 @@ const createScheduleRunner = (deps) => {
|
|
|
228
321
|
loaded.dirty = false;
|
|
229
322
|
if (missed > 0)
|
|
230
323
|
deps.log(`schedule ${schedule.id} (${schedule.label}): missed ${missed} run${missed === 1 ? '' : 's'}`);
|
|
231
|
-
if (
|
|
324
|
+
if (begun)
|
|
325
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): ${begun}`);
|
|
326
|
+
if (runs && !intoSession)
|
|
232
327
|
toStart.push({ schedule, st, before, at: now });
|
|
233
328
|
}
|
|
234
329
|
for (const { schedule, st, before, at: now } of toStart) {
|
|
@@ -302,11 +397,95 @@ const createScheduleRunner = (deps) => {
|
|
|
302
397
|
};
|
|
303
398
|
// Prompts typed whose "done" could not be written (`session@startedAt`); in memory, so only for this listener's life.
|
|
304
399
|
const delivered = new Set();
|
|
305
|
-
// Whether any
|
|
400
|
+
// Whether any prompt is still waiting (a started session's or a session target's): `poll` costs nothing while none is.
|
|
306
401
|
let watching = true;
|
|
307
|
-
|
|
402
|
+
// Why each session target's wait has not typed yet (`<id>@<since>`), for the log and outcome when it runs out. In
|
|
403
|
+
// memory: after a restart the reason starts over, which only changes the log line.
|
|
404
|
+
const waitNotes = new Map();
|
|
405
|
+
/**
|
|
406
|
+
* Types the prompts of session targets into their runs: when the run is idle and nobody has used a terminal attached
|
|
407
|
+
* to it for `SESSION_QUIET_MS`. One prompt per session per pass, the earliest due first — the next waits for the
|
|
408
|
+
* session to be idle again. A run that is over fails the wait; one tmux cannot find right now is waited for.
|
|
409
|
+
*/
|
|
410
|
+
const deliver = (loaded, now) => {
|
|
411
|
+
const { schedules, state } = loaded;
|
|
412
|
+
const typedInto = new Set();
|
|
413
|
+
const waiting = schedules
|
|
414
|
+
.filter((schedule) => state[schedule.id].delivering)
|
|
415
|
+
.sort((a, b) => state[a.id].cursor - state[b.id].cursor || a.id.localeCompare(b.id));
|
|
416
|
+
for (const schedule of waiting) {
|
|
417
|
+
const st = state[schedule.id];
|
|
418
|
+
const delivering = st.delivering;
|
|
419
|
+
const label = `schedule ${schedule.id} (${schedule.label})`;
|
|
420
|
+
const deliveredKey = `${schedule.id}@${delivering.since}`;
|
|
421
|
+
if (delivered.has(deliveredKey)) {
|
|
422
|
+
delete st.delivering;
|
|
423
|
+
loaded.dirty = true;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (schedule.prompt === undefined || schedule.runId === undefined) {
|
|
427
|
+
// `validateSchedule` gives every session target both; a wait without them has nothing to type.
|
|
428
|
+
deps.log(`! ${label}: a session target without a prompt or a run; its wait is dropped`);
|
|
429
|
+
delete st.delivering;
|
|
430
|
+
loaded.dirty = true;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const note = waitNotes.get(deliveredKey) ?? { reason: 'it was not idle' };
|
|
434
|
+
waitNotes.set(deliveredKey, note);
|
|
435
|
+
// Asked before the deadline: a run that ended while its prompt waited is over, not busy.
|
|
436
|
+
const where = deps.sessionOfRun(schedule.runId, delivering.session, Date.parse(schedule.createdAt));
|
|
437
|
+
if (where === 'ended') {
|
|
438
|
+
waitNotes.delete(deliveredKey);
|
|
439
|
+
settle(st, 'session_ended', now);
|
|
440
|
+
deps.log(`${label}: the session it types into has ended`);
|
|
441
|
+
saveNow(loaded);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (now - delivering.since > exports.SESSION_WAIT_MS) {
|
|
445
|
+
waitNotes.delete(deliveredKey);
|
|
446
|
+
settle(st, note.injectFailed ? 'prompt_undelivered' : 'session_busy', now);
|
|
447
|
+
deps.log(`${label}: not typed within ${exports.SESSION_WAIT_MS / 60_000} min (${note.reason}); skipped`);
|
|
448
|
+
saveNow(loaded);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (where === 'unknown') {
|
|
452
|
+
note.reason = 'tmux could not say where the run is';
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (where.name !== delivering.session) {
|
|
456
|
+
delivering.session = where.name;
|
|
457
|
+
loaded.dirty = true;
|
|
458
|
+
}
|
|
459
|
+
if (typedInto.has(where.name))
|
|
460
|
+
continue;
|
|
461
|
+
const used = deps.lastClientActivity(where.name);
|
|
462
|
+
if (used === undefined || (used !== null && now - used < exports.SESSION_QUIET_MS)) {
|
|
463
|
+
note.reason = used === undefined ? 'tmux could not say who uses its terminal' : 'someone was typing at its terminal';
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!deps.ready(where.name, schedule.agentKind)) {
|
|
467
|
+
note.reason = 'it was not idle';
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!deps.inject(where.name, schedule.prompt)) {
|
|
471
|
+
// Rate limit, no pane …: the next poll tries again, and the wait ends as undelivered, not busy.
|
|
472
|
+
note.reason = 'the prompt would not go in';
|
|
473
|
+
note.injectFailed = true;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
waitNotes.delete(deliveredKey);
|
|
477
|
+
typedInto.add(where.name);
|
|
478
|
+
settle(st, 'started', now);
|
|
479
|
+
deps.log(`${label}: prompt typed into ${where.name}`);
|
|
480
|
+
// On disk right away, as for a started session's prompt: a restart must not type it again.
|
|
481
|
+
if (!saveNow(loaded))
|
|
482
|
+
delivered.add(deliveredKey);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
/** Delivers the prompts of running sessions and session targets, ends sessions past their runtime cap, then saves what changed. */
|
|
308
486
|
const supervise = (loaded, now) => {
|
|
309
487
|
const { schedules, state } = loaded;
|
|
488
|
+
deliver(loaded, now);
|
|
310
489
|
for (const schedule of schedules) {
|
|
311
490
|
const st = state[schedule.id];
|
|
312
491
|
const running = st.running;
|
|
@@ -370,7 +549,7 @@ const createScheduleRunner = (deps) => {
|
|
|
370
549
|
saveNow(loaded);
|
|
371
550
|
}
|
|
372
551
|
}
|
|
373
|
-
watching = Object.values(state).some((st) => st.running?.promptPending);
|
|
552
|
+
watching = Object.values(state).some((st) => st.running?.promptPending || st.delivering);
|
|
374
553
|
if (loaded.dirty)
|
|
375
554
|
loaded.save();
|
|
376
555
|
};
|
package/dist/schedule.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Scheduled sessions (F8): what a schedule is, when it comes due, and the files it lives in.
|
|
3
3
|
*
|
|
4
4
|
* A schedule is one file, `<stateDir>/schedules/<id>.json` (0600). `hwip schedule` writes and
|
|
5
|
-
* deletes them, the listener only reads them
|
|
6
|
-
* never lose each other's change. What the listener has done about each one lives in its own file
|
|
5
|
+
* deletes them, the listener only reads them — except a `once` schedule that has run, which the listener
|
|
6
|
+
* deletes — and nothing reads-then-rewrites a file, so the two never lose each other's change. What the listener has done about each one lives in its own file
|
|
7
7
|
* (`schedule-state.json`, `schedule-runner.ts`).
|
|
8
8
|
*
|
|
9
9
|
* Everything here is pure or touches only that directory. The clock is a parameter.
|
|
@@ -15,6 +15,11 @@ export type ScheduleWhen = {
|
|
|
15
15
|
} | {
|
|
16
16
|
kind: 'every';
|
|
17
17
|
minutes: number;
|
|
18
|
+
}
|
|
19
|
+
/** Runs one time, at `at` (an ISO 8601 instant), and is removed after (`schedule-runner.ts`). */
|
|
20
|
+
| {
|
|
21
|
+
kind: 'once';
|
|
22
|
+
at: string;
|
|
18
23
|
};
|
|
19
24
|
export interface Schedule {
|
|
20
25
|
v: 1;
|
|
@@ -29,8 +34,18 @@ export interface Schedule {
|
|
|
29
34
|
cwd: string;
|
|
30
35
|
/** Passed to `hwip <agent> --label`; the default is `sched-<id>`. */
|
|
31
36
|
label: string;
|
|
32
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The tmux session the launch will create, worked out when the schedule is made. For a `target: 'session'` schedule,
|
|
39
|
+
* the session the run was in when the schedule was made: where the runner looks for it first.
|
|
40
|
+
*/
|
|
33
41
|
session: string;
|
|
42
|
+
/**
|
|
43
|
+
* `session`: no session is started; at its time the prompt is typed into the run `runId` is, wherever it goes on
|
|
44
|
+
* (`liveSessionOfRun`). Absent: a new session is started (`hwip <agent> --detach`). Fixed once made.
|
|
45
|
+
*/
|
|
46
|
+
target?: 'session';
|
|
47
|
+
/** The run (hwip docs/session-runs.md) a `target: 'session'` schedule types into, and only with it. */
|
|
48
|
+
runId?: string;
|
|
34
49
|
/** `--wt <branch>`. From the command line only. */
|
|
35
50
|
wt?: string;
|
|
36
51
|
/** Arguments after `--`. From the command line only. */
|
|
@@ -39,6 +54,12 @@ export interface Schedule {
|
|
|
39
54
|
when: ScheduleWhen;
|
|
40
55
|
maxRuntimeMin: number;
|
|
41
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Advertised in the listener's presence (`device-presence.ts`): this build runs `once` schedules and schedules into a
|
|
59
|
+
* session (`target: 'session'`). An older listener refuses both as fields it does not know, so the apps offer them only
|
|
60
|
+
* to a computer that lists this.
|
|
61
|
+
*/
|
|
62
|
+
export declare const SCHEDULE_SESSION_CAPABILITY = "schedule-session-v1";
|
|
42
63
|
export type Checked<T> = {
|
|
43
64
|
ok: true;
|
|
44
65
|
value: T;
|
|
@@ -54,11 +75,12 @@ export declare const ALL_DAYS: readonly number[];
|
|
|
54
75
|
export declare const WEEKDAYS: readonly number[];
|
|
55
76
|
/** `<N>m` or `<N>h` as minutes; `example` is what the error shows for a value that is neither. */
|
|
56
77
|
export declare const parseDuration: (raw: string, flag: string, example: string) => Checked<number>;
|
|
57
|
-
/** `--at HH:MM [--days …]` or `--every <N>m|<N>h`,
|
|
78
|
+
/** `--at HH:MM [--days …]`, `--on YYYY-MM-DD --at HH:MM` or `--every <N>m|<N>h`, one of them and not neither. */
|
|
58
79
|
export declare const parseWhen: (input: {
|
|
59
80
|
at?: string;
|
|
60
81
|
days?: string;
|
|
61
82
|
every?: string;
|
|
83
|
+
on?: string;
|
|
62
84
|
}) => Checked<ScheduleWhen>;
|
|
63
85
|
export interface Occurrences {
|
|
64
86
|
count: number;
|
|
@@ -75,7 +97,7 @@ export interface Occurrences {
|
|
|
75
97
|
* the clock repeats (fall) happens once, the first time round.
|
|
76
98
|
*/
|
|
77
99
|
export declare const occurrencesBetween: (when: ScheduleWhen, after: number, until: number, anchor: number) => Occurrences;
|
|
78
|
-
/** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
100
|
+
/** The first time after `after` the schedule comes due; null for a `once` that is past and a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
79
101
|
export declare const nextOccurrence: (when: ScheduleWhen, after: number, anchor: number) => number | null;
|
|
80
102
|
export declare const isScheduleId: (value: unknown) => value is string;
|
|
81
103
|
/**
|
|
@@ -134,12 +156,14 @@ export type UpdateResult = {
|
|
|
134
156
|
* missed. A change of the prompt or the run time only keeps the count where it was — an hourly run stays on its hour.
|
|
135
157
|
*/
|
|
136
158
|
export declare const updateSchedule: (id: string, change: UncheckedScheduleChange, now: number) => UpdateResult;
|
|
159
|
+
/** Why `when` cannot be given to a schedule at `now`: a `once` whose time has passed would never run. Null when it can. */
|
|
160
|
+
export declare const pastWhen: (when: ScheduleWhen, now: number) => string | null;
|
|
137
161
|
/** The time a schedule's runs are counted from: its last edit, else its creation. */
|
|
138
162
|
export declare const scheduleAnchor: (schedule: Schedule) => number;
|
|
139
163
|
/** `invalid` means the id could not name a schedule file; nothing was touched. */
|
|
140
164
|
export type RemoveResult = 'removed' | 'missing' | 'invalid';
|
|
141
165
|
export declare const removeSchedule: (id: string) => RemoveResult;
|
|
142
|
-
declare const SCHEDULE_OUTCOMES: readonly ["started", "missed", "overlap", "busy", "start_failed", "missing_directory", "unknown_agent", "prompt_undelivered", "prompt_unsupported", "capped"];
|
|
166
|
+
declare const SCHEDULE_OUTCOMES: readonly ["started", "missed", "overlap", "busy", "start_failed", "missing_directory", "unknown_agent", "prompt_undelivered", "prompt_unsupported", "capped", "session_busy", "session_ended"];
|
|
143
167
|
export type ScheduleOutcome = (typeof SCHEDULE_OUTCOMES)[number];
|
|
144
168
|
/** Per schedule, in `schedule-state.json`. Only the listener writes it (`schedule-runner.ts`); `hwip schedule list` reads it. */
|
|
145
169
|
export interface ScheduleRunState {
|
|
@@ -152,6 +176,14 @@ export interface ScheduleRunState {
|
|
|
152
176
|
promptPending: boolean;
|
|
153
177
|
capped?: true;
|
|
154
178
|
};
|
|
179
|
+
/**
|
|
180
|
+
* A session target whose time came and whose prompt waits for its session to be idle. `session` is where the run was
|
|
181
|
+
* last seen; `since` is when the wait began, and the wait is given up on 30 minutes after it (`schedule-runner.ts`).
|
|
182
|
+
*/
|
|
183
|
+
delivering?: {
|
|
184
|
+
session: string;
|
|
185
|
+
since: number;
|
|
186
|
+
};
|
|
155
187
|
last?: {
|
|
156
188
|
outcome: ScheduleOutcome;
|
|
157
189
|
at: number;
|