pi-harness-runtime 1.1.459 → 1.1.461
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/CHANGELOG.md +77 -0
- package/harness/pending-countdown.ts +348 -0
- package/harness/pending-wait.ts +69 -0
- package/index.ts +248 -48
- package/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/write-review/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,83 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.1.461](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.461) (2026-10-28)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **delegation:** a child is only a child when it really ran ([ac4e678](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ac4e6782d7d4bc4941e458425c8a9f38a51fc9d4))
|
|
11
|
+
* **delegation:** the scope of a request is measured before it is judged ([684b6a5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/684b6a55184f63d3be24c48dcecfb0b13410dc15))
|
|
12
|
+
* **honcho:** the memory service speaks the native MCP of Pi ([f9f740c](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f9f740c12869bae4c4380afbe8c76e12388bb744))
|
|
13
|
+
* **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
|
|
14
|
+
* **jev:** the pending question counts down to the decision ([d0cc171](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d0cc171223986b2d32c670b6c1c74712fe0a4ad8))
|
|
15
|
+
* **keys:** the key files are repaired when their permissions are unsafe ([e9c2c26](https://github.com/ManotLuijiu/pi-harness-runtime/commit/e9c2c26b287665b56ed749de269737e8403bb8af))
|
|
16
|
+
* **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
|
|
17
|
+
* **phase:** the agent owns the todo list and the harness says so ([c489370](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c489370bd7926aece8cb1a77aa20ca2d2672f1fb))
|
|
18
|
+
* **review:** the review starts on its own when the implementation is done ([04f51f5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/04f51f599cd3222b6c74dd8fadb1373bd72d9bd7))
|
|
19
|
+
* **status:** the status line names the phase, the progress and the children ([0167bf3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/0167bf396a0b987c1d44f3b869351976a951dd65))
|
|
20
|
+
* **test:** the task waits for the final tier of the validation plan ([fa147db](https://github.com/ManotLuijiu/pi-harness-runtime/commit/fa147dbf7d50d56e6ea48c6d8fbb4fcb190caf8a))
|
|
21
|
+
* **validation:** a large job starts with a measured pilot ([425964b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/425964b04a3d4fa02adfabed47ff62c5ec2b9c28))
|
|
22
|
+
* **validation:** the size of a job is a fact, not a phrase ([8aa6c11](https://github.com/ManotLuijiu/pi-harness-runtime/commit/8aa6c119565d0f8412dc8aeafa6b295d47d3f093))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **ci:** every run of a required check counts, and the worst one decides ([da9e0da](https://github.com/ManotLuijiu/pi-harness-runtime/commit/da9e0da6b0ca29cb320764fd5fc1b8a8dee8b1e0))
|
|
28
|
+
* **ci:** the newest attempt of a run decides, not the attempt that failed ([9d7adcc](https://github.com/ManotLuijiu/pi-harness-runtime/commit/9d7adcc31cbacc517c3c1a9bcd3956be94c908ef))
|
|
29
|
+
* **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
|
|
30
|
+
* **jev:** a session switch ends the wait of the old session ([ca03d39](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ca03d399c0d4a1b2dd428ac39836ab53c0e51ab6))
|
|
31
|
+
* **jev:** the alternatives of a question are parsed without a quadratic pattern ([63016a3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/63016a3f0488acb801990dd46770c00d382b5858))
|
|
32
|
+
* **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
|
|
33
|
+
* **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
|
|
34
|
+
* **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
|
|
35
|
+
* **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
|
|
36
|
+
* **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
|
|
37
|
+
* **release:** a manual release keeps the version of its manifest ([122e661](https://github.com/ManotLuijiu/pi-harness-runtime/commit/122e661ea48cb8aff14c71dbd0221a4cf0ba4c8e))
|
|
38
|
+
* **release:** the alignment names the reason of a failure ([5814dd0](https://github.com/ManotLuijiu/pi-harness-runtime/commit/5814dd0b780f5839cf8011ed0cbbbb31e7eb0418))
|
|
39
|
+
* **release:** the channels of the package name the release ([a7a273f](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a7a273ffe8398e2b5b07ef1c321777de41792c6e))
|
|
40
|
+
* **release:** the release publishes and leaves the channels alone ([5871746](https://github.com/ManotLuijiu/pi-harness-runtime/commit/5871746589c1281d855dbeb591b02419fa43f22e))
|
|
41
|
+
* **release:** the release pull request names the version of its branch ([a846e83](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a846e83e6de6e529f4bca05e869db0b5eebb37c5))
|
|
42
|
+
* **release:** the release scans the tree of the commit it publishes ([7d47a0b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7d47a0b5406480a1e1d3a52623a76aabd1e2d0ba))
|
|
43
|
+
|
|
44
|
+
### [1.1.460](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.460) (2026-10-28)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Features
|
|
48
|
+
|
|
49
|
+
* **delegation:** a child is only a child when it really ran ([ac4e678](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ac4e6782d7d4bc4941e458425c8a9f38a51fc9d4))
|
|
50
|
+
* **delegation:** the scope of a request is measured before it is judged ([684b6a5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/684b6a55184f63d3be24c48dcecfb0b13410dc15))
|
|
51
|
+
* **honcho:** the memory service speaks the native MCP of Pi ([f9f740c](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f9f740c12869bae4c4380afbe8c76e12388bb744))
|
|
52
|
+
* **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
|
|
53
|
+
* **jev:** the pending question counts down to the decision ([d0cc171](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d0cc171223986b2d32c670b6c1c74712fe0a4ad8))
|
|
54
|
+
* **keys:** the key files are repaired when their permissions are unsafe ([e9c2c26](https://github.com/ManotLuijiu/pi-harness-runtime/commit/e9c2c26b287665b56ed749de269737e8403bb8af))
|
|
55
|
+
* **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
|
|
56
|
+
* **phase:** the agent owns the todo list and the harness says so ([c489370](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c489370bd7926aece8cb1a77aa20ca2d2672f1fb))
|
|
57
|
+
* **review:** the review starts on its own when the implementation is done ([04f51f5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/04f51f599cd3222b6c74dd8fadb1373bd72d9bd7))
|
|
58
|
+
* **status:** the status line names the phase, the progress and the children ([0167bf3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/0167bf396a0b987c1d44f3b869351976a951dd65))
|
|
59
|
+
* **test:** the task waits for the final tier of the validation plan ([fa147db](https://github.com/ManotLuijiu/pi-harness-runtime/commit/fa147dbf7d50d56e6ea48c6d8fbb4fcb190caf8a))
|
|
60
|
+
* **validation:** a large job starts with a measured pilot ([425964b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/425964b04a3d4fa02adfabed47ff62c5ec2b9c28))
|
|
61
|
+
* **validation:** the size of a job is a fact, not a phrase ([8aa6c11](https://github.com/ManotLuijiu/pi-harness-runtime/commit/8aa6c119565d0f8412dc8aeafa6b295d47d3f093))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
### Bug Fixes
|
|
65
|
+
|
|
66
|
+
* **ci:** every run of a required check counts, and the worst one decides ([da9e0da](https://github.com/ManotLuijiu/pi-harness-runtime/commit/da9e0da6b0ca29cb320764fd5fc1b8a8dee8b1e0))
|
|
67
|
+
* **ci:** the newest attempt of a run decides, not the attempt that failed ([9d7adcc](https://github.com/ManotLuijiu/pi-harness-runtime/commit/9d7adcc31cbacc517c3c1a9bcd3956be94c908ef))
|
|
68
|
+
* **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
|
|
69
|
+
* **jev:** the alternatives of a question are parsed without a quadratic pattern ([63016a3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/63016a3f0488acb801990dd46770c00d382b5858))
|
|
70
|
+
* **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
|
|
71
|
+
* **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
|
|
72
|
+
* **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
|
|
73
|
+
* **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
|
|
74
|
+
* **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
|
|
75
|
+
* **release:** a manual release keeps the version of its manifest ([122e661](https://github.com/ManotLuijiu/pi-harness-runtime/commit/122e661ea48cb8aff14c71dbd0221a4cf0ba4c8e))
|
|
76
|
+
* **release:** the alignment names the reason of a failure ([5814dd0](https://github.com/ManotLuijiu/pi-harness-runtime/commit/5814dd0b780f5839cf8011ed0cbbbb31e7eb0418))
|
|
77
|
+
* **release:** the channels of the package name the release ([a7a273f](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a7a273ffe8398e2b5b07ef1c321777de41792c6e))
|
|
78
|
+
* **release:** the release publishes and leaves the channels alone ([5871746](https://github.com/ManotLuijiu/pi-harness-runtime/commit/5871746589c1281d855dbeb591b02419fa43f22e))
|
|
79
|
+
* **release:** the release pull request names the version of its branch ([a846e83](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a846e83e6de6e529f4bca05e869db0b5eebb37c5))
|
|
80
|
+
* **release:** the release scans the tree of the commit it publishes ([7d47a0b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7d47a0b5406480a1e1d3a52623a76aabd1e2d0ba))
|
|
81
|
+
|
|
5
82
|
### [1.1.459](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.459) (2026-10-28)
|
|
6
83
|
|
|
7
84
|
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live countdown of a pending question.
|
|
3
|
+
*
|
|
4
|
+
* The harness asks a human a question and waits. The wait has a deadline,
|
|
5
|
+
* and the countdown shows the remaining time in the status row of the TUI
|
|
6
|
+
* and starts the decision of the judge exactly at that deadline.
|
|
7
|
+
*
|
|
8
|
+
* The module owns the two policies of the wait:
|
|
9
|
+
*
|
|
10
|
+
* - the first countdown counts down to the deadline of the decision, which
|
|
11
|
+
* is derived from the moment the wait started,
|
|
12
|
+
* - the recheck counts down to the next attempt, after the judge asked to
|
|
13
|
+
* wait longer.
|
|
14
|
+
*
|
|
15
|
+
* The caller cannot pass a deadline of its own: the displayed deadline and
|
|
16
|
+
* the deadline the judge uses therefore cannot drift apart.
|
|
17
|
+
*
|
|
18
|
+
* The countdown is bound to one request. Every start replaces the previous
|
|
19
|
+
* binding and every stop releases the timer, so there is never more than
|
|
20
|
+
* one countdown and never a timer without a request.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What the countdown counts to. */
|
|
24
|
+
export type CountdownMode = "initial" | "recheck";
|
|
25
|
+
|
|
26
|
+
/** The state of the countdown that is drawn in the status row. */
|
|
27
|
+
export interface CountdownView {
|
|
28
|
+
/** The request the countdown belongs to. */
|
|
29
|
+
requestId: string;
|
|
30
|
+
/** The milliseconds that are left. Never negative. */
|
|
31
|
+
remainingMs: number;
|
|
32
|
+
/** The remaining time as "MM:SS". */
|
|
33
|
+
remaining: string;
|
|
34
|
+
/** What the countdown counts to. */
|
|
35
|
+
mode: CountdownMode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The dependencies of a countdown. */
|
|
39
|
+
export interface PendingCountdownDeps {
|
|
40
|
+
/** Draw the countdown in the status row. */
|
|
41
|
+
render: (view: CountdownView) => void;
|
|
42
|
+
/** The deadline was reached and the decision is due. */
|
|
43
|
+
onDeadline: (requestId: string) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Is the request still the pending one?
|
|
46
|
+
*
|
|
47
|
+
* A tick of a question that was answered or replaced in the meantime
|
|
48
|
+
* must not redraw its status and must not trigger a decision.
|
|
49
|
+
*/
|
|
50
|
+
isPending: (requestId: string) => boolean;
|
|
51
|
+
/** A failing callback of the countdown is reported here. */
|
|
52
|
+
onError?: (error: unknown) => void;
|
|
53
|
+
/** The wait is over: the status row is cleared. */
|
|
54
|
+
onStop?: (requestId: string | null) => void;
|
|
55
|
+
/** The minutes the judge waits for a human answer. */
|
|
56
|
+
waitMinutes: number;
|
|
57
|
+
/** The milliseconds until the next attempt after the judge asked to wait. */
|
|
58
|
+
recheckIntervalMs: number;
|
|
59
|
+
/** The clock (the tests drive it). */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** The timer that drives the ticks (the tests drive it). */
|
|
62
|
+
setInterval?: (callback: () => void, ms: number) => unknown;
|
|
63
|
+
/** The timer that stops the ticks (the tests drive it). */
|
|
64
|
+
clearInterval?: (handle: unknown) => void;
|
|
65
|
+
/** The tick interval in milliseconds. */
|
|
66
|
+
tickMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The milliseconds between two ticks of the display. */
|
|
70
|
+
const COUNTDOWN_TICK_MS = 1_000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The smallest tick interval.
|
|
74
|
+
*
|
|
75
|
+
* A shorter interval would repaint the status row faster than a reader can
|
|
76
|
+
* follow it and would waste the event loop of the harness.
|
|
77
|
+
*/
|
|
78
|
+
const MIN_TICK_MS = 100;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The deadline of the decision.
|
|
82
|
+
*
|
|
83
|
+
* The deadline is derived from the moment the wait started, so the
|
|
84
|
+
* countdown and the judge share one timestamp.
|
|
85
|
+
*/
|
|
86
|
+
export function decisionDeadlineAt(startedAt: number, waitMinutes: number): number {
|
|
87
|
+
const minutes = Number.isFinite(waitMinutes) ? Math.max(0, waitMinutes) : 0;
|
|
88
|
+
const started = Number.isFinite(startedAt) ? startedAt : 0;
|
|
89
|
+
return started + minutes * 60_000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The remaining time as "MM:SS".
|
|
94
|
+
*
|
|
95
|
+
* A time that is not a number is shown as an empty countdown: a status row
|
|
96
|
+
* must never claim a time that does not exist.
|
|
97
|
+
*/
|
|
98
|
+
export function formatRemainingTime(remainingMs: number): string {
|
|
99
|
+
const safeMs = Number.isFinite(remainingMs) ? Math.max(0, remainingMs) : 0;
|
|
100
|
+
const totalSeconds = Math.floor(safeMs / 1_000);
|
|
101
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
102
|
+
const seconds = totalSeconds % 60;
|
|
103
|
+
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The binding of a running countdown. */
|
|
107
|
+
interface CountdownBinding {
|
|
108
|
+
requestId: string;
|
|
109
|
+
deadlineAt: number;
|
|
110
|
+
mode: CountdownMode;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The countdown of one pending question.
|
|
115
|
+
*
|
|
116
|
+
* The countdown renders at once: the status row must show the remaining
|
|
117
|
+
* time without waiting for the first tick.
|
|
118
|
+
*/
|
|
119
|
+
export class PendingQuestionCountdown {
|
|
120
|
+
private readonly deps: PendingCountdownDeps;
|
|
121
|
+
private readonly now: () => number;
|
|
122
|
+
private readonly startTimer: (callback: () => void, ms: number) => unknown;
|
|
123
|
+
private readonly stopTimer: (handle: unknown) => void;
|
|
124
|
+
private readonly tickMs: number;
|
|
125
|
+
private handle: unknown = null;
|
|
126
|
+
private binding: CountdownBinding | null = null;
|
|
127
|
+
/**
|
|
128
|
+
* Bumped on every start and every stop.
|
|
129
|
+
*
|
|
130
|
+
* A tick of an older generation belongs to a binding that is gone: it is
|
|
131
|
+
* ignored instead of redrawing a status or triggering a decision.
|
|
132
|
+
*/
|
|
133
|
+
private generation = 0;
|
|
134
|
+
/**
|
|
135
|
+
* True while the countdown owns the wait.
|
|
136
|
+
*
|
|
137
|
+
* The countdown stays engaged after its deadline fired, until the
|
|
138
|
+
* decision path stops it or replaces it. The fallback poll uses this to
|
|
139
|
+
* stay out of the way of the accurate timer.
|
|
140
|
+
*/
|
|
141
|
+
private engaged = false;
|
|
142
|
+
/**
|
|
143
|
+
* True when the deadline fired and the decision did not start yet.
|
|
144
|
+
*
|
|
145
|
+
* The countdown owns the wait in that state, but it has no timer: the
|
|
146
|
+
* caller arms it again when the decision did not start.
|
|
147
|
+
*/
|
|
148
|
+
private deadlineFired = false;
|
|
149
|
+
|
|
150
|
+
constructor(deps: PendingCountdownDeps) {
|
|
151
|
+
this.deps = deps;
|
|
152
|
+
this.now = deps.now ?? (() => Date.now());
|
|
153
|
+
this.startTimer =
|
|
154
|
+
deps.setInterval ??
|
|
155
|
+
((callback, ms) => setInterval(callback, ms) as unknown);
|
|
156
|
+
this.stopTimer =
|
|
157
|
+
deps.clearInterval ??
|
|
158
|
+
((handle) => clearInterval(handle as NodeJS.Timeout));
|
|
159
|
+
this.tickMs = Math.max(MIN_TICK_MS, deps.tickMs ?? COUNTDOWN_TICK_MS);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Does the countdown own the current wait? */
|
|
163
|
+
get active(): boolean {
|
|
164
|
+
return this.engaged;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Did the deadline fire and is the decision still due? */
|
|
168
|
+
get fired(): boolean {
|
|
169
|
+
return this.deadlineFired;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The request the countdown belongs to. */
|
|
173
|
+
get requestId(): string | null {
|
|
174
|
+
return this.binding?.requestId ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What the countdown counts to. */
|
|
178
|
+
get mode(): CountdownMode | null {
|
|
179
|
+
return this.binding?.mode ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start the countdown of a request, or replace the running one.
|
|
184
|
+
*
|
|
185
|
+
* The deadline of the decision is derived from the moment the wait
|
|
186
|
+
* started: the caller passes that timestamp, the countdown computes the
|
|
187
|
+
* deadline. The countdown and the judge therefore share one timestamp.
|
|
188
|
+
*/
|
|
189
|
+
start(requestId: string, startedAt: number): void {
|
|
190
|
+
this.begin(requestId, decisionDeadlineAt(startedAt, this.deps.waitMinutes), "initial");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Start the countdown of the next attempt.
|
|
195
|
+
*
|
|
196
|
+
* The judge asked to wait longer: the countdown counts down to the next
|
|
197
|
+
* attempt instead of to the deadline of the decision.
|
|
198
|
+
*/
|
|
199
|
+
startRecheck(requestId: string): void {
|
|
200
|
+
this.begin(requestId, this.now() + this.deps.recheckIntervalMs, "recheck");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Stop the countdown and release its timer.
|
|
205
|
+
*
|
|
206
|
+
* Stopping is idempotent: a countdown that is not running can be stopped
|
|
207
|
+
* as often as the cleanup paths need. The status row is cleared with it,
|
|
208
|
+
* so a dead countdown never leaves a row that claims a decision is due.
|
|
209
|
+
*/
|
|
210
|
+
stop(): void {
|
|
211
|
+
const requestId = this.binding?.requestId ?? null;
|
|
212
|
+
this.engaged = false;
|
|
213
|
+
this.deadlineFired = false;
|
|
214
|
+
this.generation += 1;
|
|
215
|
+
this.clearTimer();
|
|
216
|
+
this.binding = null;
|
|
217
|
+
this.safeCall(() => this.deps.onStop?.(requestId));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Bind the countdown to a request and start its timer. */
|
|
221
|
+
private begin(requestId: string, deadlineAt: number, mode: CountdownMode): void {
|
|
222
|
+
this.clearTimer();
|
|
223
|
+
this.generation += 1;
|
|
224
|
+
const generation = this.generation;
|
|
225
|
+
this.binding = { requestId, deadlineAt, mode };
|
|
226
|
+
this.engaged = true;
|
|
227
|
+
this.deadlineFired = false;
|
|
228
|
+
try {
|
|
229
|
+
this.handle = this.startTimer(() => this.tick(generation), this.tickMs);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// The timer could not be created: the countdown owns nothing, so
|
|
232
|
+
// it must not claim the wait. The row of the countdown is
|
|
233
|
+
// cleared with it, so no row claims a decision that is not due.
|
|
234
|
+
this.engaged = false;
|
|
235
|
+
this.binding = null;
|
|
236
|
+
this.safeCall(() => this.deps.onStop?.(requestId));
|
|
237
|
+
this.report(error);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.tick(generation);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One tick of the countdown.
|
|
245
|
+
*
|
|
246
|
+
* The tick is a no-op when it belongs to a binding that is gone, or
|
|
247
|
+
* when the request it counts for is no longer the pending one.
|
|
248
|
+
*/
|
|
249
|
+
private tick(generation: number): void {
|
|
250
|
+
if (!this.engaged || generation !== this.generation) return;
|
|
251
|
+
const binding = this.binding;
|
|
252
|
+
if (!binding) return;
|
|
253
|
+
|
|
254
|
+
let remainingMs: number;
|
|
255
|
+
try {
|
|
256
|
+
if (!this.deps.isPending(binding.requestId)) {
|
|
257
|
+
// The question was answered or replaced: the countdown of
|
|
258
|
+
// that question is over.
|
|
259
|
+
this.stop();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// The callback may have stopped the countdown: the binding of
|
|
263
|
+
// this tick is gone and must not be drawn any more.
|
|
264
|
+
if (!this.engaged || generation !== this.generation) return;
|
|
265
|
+
remainingMs = Math.max(0, binding.deadlineAt - this.now());
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// A failing callback must never escape the timer: the countdown
|
|
268
|
+
// stops and the failure is reported.
|
|
269
|
+
this.stop();
|
|
270
|
+
this.report(error);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (remainingMs <= 0) {
|
|
275
|
+
// The deadline is reached. The timer stops, the countdown stays
|
|
276
|
+
// engaged until the decision path stops it or replaces it, so
|
|
277
|
+
// the fallback poll does not start a second decision.
|
|
278
|
+
this.clearTimer();
|
|
279
|
+
this.deadlineFired = true;
|
|
280
|
+
// The countdown reaches its zero before the decision starts.
|
|
281
|
+
this.safeRender({
|
|
282
|
+
requestId: binding.requestId,
|
|
283
|
+
remainingMs: 0,
|
|
284
|
+
remaining: formatRemainingTime(0),
|
|
285
|
+
mode: binding.mode,
|
|
286
|
+
});
|
|
287
|
+
// A failing decision handler must not leave a countdown that owns
|
|
288
|
+
// the wait without a timer: the countdown stops, so the fallback
|
|
289
|
+
// poll can take over.
|
|
290
|
+
try {
|
|
291
|
+
this.deps.onDeadline(binding.requestId);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
this.stop();
|
|
294
|
+
this.report(error);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.safeRender({
|
|
300
|
+
requestId: binding.requestId,
|
|
301
|
+
remainingMs,
|
|
302
|
+
remaining: formatRemainingTime(remainingMs),
|
|
303
|
+
mode: binding.mode,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Draw the countdown, and report a failing status row. */
|
|
308
|
+
private safeRender(view: CountdownView): void {
|
|
309
|
+
try {
|
|
310
|
+
this.deps.render(view);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
this.stop();
|
|
313
|
+
this.report(error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Run a callback of the countdown without letting it escape. */
|
|
318
|
+
private safeCall(action: () => void): void {
|
|
319
|
+
try {
|
|
320
|
+
action();
|
|
321
|
+
} catch (error) {
|
|
322
|
+
this.report(error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Report a failure without letting the report escape. */
|
|
327
|
+
private report(error: unknown): void {
|
|
328
|
+
try {
|
|
329
|
+
this.deps.onError?.(error);
|
|
330
|
+
} catch {
|
|
331
|
+
// The report of a failure must not become a new failure.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Release the timer of the countdown. */
|
|
336
|
+
private clearTimer(): void {
|
|
337
|
+
if (this.handle === null) return;
|
|
338
|
+
const handle = this.handle;
|
|
339
|
+
this.handle = null;
|
|
340
|
+
try {
|
|
341
|
+
this.stopTimer(handle);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
// The timer is already released from the point of view of the
|
|
344
|
+
// countdown: the failure is reported, nothing is left behind.
|
|
345
|
+
this.report(error);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end of a wait for a pending question.
|
|
3
|
+
*
|
|
4
|
+
* The countdown, the wait state and the pending question belong together:
|
|
5
|
+
* when one of them ends, the others end with it. A wait that outlives its
|
|
6
|
+
* question lets the judge decide about a question that belongs to a session
|
|
7
|
+
* that is gone - the fallback poll would ask about a question nobody can
|
|
8
|
+
* answer any more.
|
|
9
|
+
*
|
|
10
|
+
* The helper owns that release, so every way out of a wait ends the same
|
|
11
|
+
* way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The question that is waiting for an answer. */
|
|
15
|
+
export interface PendingWaitQuestion {
|
|
16
|
+
requestId: string;
|
|
17
|
+
state: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The pieces of the harness that the release of a wait touches. */
|
|
21
|
+
export interface PendingWaitOwner {
|
|
22
|
+
/** The question that is waiting for an answer, if there is one. */
|
|
23
|
+
currentQuestion(): PendingWaitQuestion | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Close the question.
|
|
26
|
+
*
|
|
27
|
+
* Returns true when the question was closed. A question that is not
|
|
28
|
+
* pending any more needs no closing.
|
|
29
|
+
*/
|
|
30
|
+
expireQuestion(requestId: string, reason: string): boolean;
|
|
31
|
+
/** Clear the wait state: the wait is over. */
|
|
32
|
+
clearWait(): void;
|
|
33
|
+
/** Stop the countdown of the wait and release its timer. */
|
|
34
|
+
stopCountdown(): void;
|
|
35
|
+
/** Clear the status row of the wait. */
|
|
36
|
+
clearStatus(): void;
|
|
37
|
+
/** Record the transition of the question. */
|
|
38
|
+
logTransition(requestId: string, outcome: string, reason: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* End the wait for a pending question.
|
|
43
|
+
*
|
|
44
|
+
* The question is closed first, so nothing can decide about it any more,
|
|
45
|
+
* and the wait state follows. The reason names the way out, so the log of
|
|
46
|
+
* the transition says why the question was closed.
|
|
47
|
+
*/
|
|
48
|
+
export function endPendingWait(reason: string, owner: PendingWaitOwner): void {
|
|
49
|
+
// The countdown of the question is over with the wait: a tick of it
|
|
50
|
+
// must not draw a status and must not start a decision.
|
|
51
|
+
owner.stopCountdown();
|
|
52
|
+
|
|
53
|
+
// The question is closed explicitly, so the buttons of that request do
|
|
54
|
+
// not stay open and look like a request that is still waiting.
|
|
55
|
+
const question = owner.currentQuestion();
|
|
56
|
+
if (question && question.state === "pending") {
|
|
57
|
+
const expired = owner.expireQuestion(question.requestId, reason) === true;
|
|
58
|
+
owner.logTransition(
|
|
59
|
+
question.requestId,
|
|
60
|
+
expired ? "expired" : "closed",
|
|
61
|
+
reason,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The wait state is over with the question: the judge is not asked
|
|
66
|
+
// about a question of a session that is gone.
|
|
67
|
+
owner.clearWait();
|
|
68
|
+
owner.clearStatus();
|
|
69
|
+
}
|