-
-
Notifications
You must be signed in to change notification settings - Fork 538
Expand file tree
/
Copy pathrunnerLifecycle.ts
More file actions
178 lines (155 loc) · 6.03 KB
/
Copy pathrunnerLifecycle.ts
File metadata and controls
178 lines (155 loc) · 6.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import type { ApiSessionClient } from '@/api/apiSession'
import type { SessionEndReason } from '@hapi/protocol'
import { logger } from '@/ui/logger'
import { restoreTerminalState } from '@/ui/terminalState'
type RunnerLifecycleOptions = {
session: ApiSessionClient
logTag: string
stopKeepAlive?: () => void
onBeforeClose?: () => Promise<void> | void
onAfterClose?: () => Promise<void> | void
}
export type RunnerLifecycle = {
setExitCode: (code: number) => void
setArchiveReason: (reason: string) => void
setSessionEndReason: (reason: SessionEndReason) => void
markCrash: (error: unknown) => void
cleanup: () => Promise<void>
cleanupAndExit: (codeOverride?: number) => Promise<void>
registerProcessHandlers: () => void
}
export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLifecycle {
let exitCode = 0
// tiann/hapi#914: default reason is 'Hub restart' (parent-driven SIGTERM
// is the most common non-user cause). Genuine user actions (clicking
// Archive in the web UI, or Ctrl-C in a local terminal) explicitly
// reassign this via `setArchiveReason` BEFORE `cleanupAndExit` runs:
// - KillSession RPC handler → 'User terminated' (see registerKillSessionHandler)
// - SIGINT handler → 'User terminated' (Ctrl-C in local terminal)
// - uncaughtException/Reject → 'Session crashed' (via markCrash)
//
// Out-of-band SIGTERM (hub-restart cascade, systemd cgroup kill on
// hapi-runner.service stop, `kill <pid>` from the operator) keeps the
// default and is correctly labelled 'Hub restart' on the audit trail.
//
// Runner-internal stop paths (`hapi runner stop-session`, webhook-timeout
// cleanup at run.ts:587, orphan cleanup at run.ts:267) also currently
// hit this default - that is technically inaccurate but follows the
// friction-mode "smallest defensible change" rule for this PR. Finer
// attribution would require an IPC channel (stdio: 'ipc' on spawn) so
// the runner can stamp `setArchiveReason` before SIGTERMing; tracked as
// a follow-up to keep this PR focussed on the user-action lie that
// motivated #914.
let archiveReason = 'Hub restart'
let sessionEndReason: SessionEndReason = 'terminated'
let cleanupStarted = false
let cleanupPromise: Promise<void> | null = null
const logPrefix = `[${options.logTag}]`
const archiveAndClose = async () => {
options.session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason
}))
options.session.sendSessionDeath(sessionEndReason)
await options.session.flush()
await options.session.close()
}
const cleanup = async () => {
if (cleanupPromise) {
return cleanupPromise
}
cleanupStarted = true
cleanupPromise = (async () => {
logger.debug(`${logPrefix} Cleanup start`)
restoreTerminalState()
try {
options.stopKeepAlive?.()
await options.onBeforeClose?.()
await archiveAndClose()
logger.debug(`${logPrefix} Cleanup complete`)
} finally {
try {
await options.onAfterClose?.()
} catch (error) {
logger.debug(`${logPrefix} Error during post-cleanup:`, error)
}
}
})()
return cleanupPromise
}
const cleanupAndExit = async (codeOverride?: number) => {
if (codeOverride !== undefined) {
exitCode = codeOverride
}
try {
await cleanup()
process.exit(exitCode)
} catch (error) {
logger.debug(`${logPrefix} Error during cleanup:`, error)
process.exit(1)
}
}
const setExitCode = (code: number) => {
exitCode = code
}
const setArchiveReason = (reason: string) => {
archiveReason = reason
}
const setSessionEndReason = (reason: SessionEndReason) => {
sessionEndReason = reason
}
const markCrash = (error: unknown) => {
logger.debug(`${logPrefix} Unhandled error:`, error)
exitCode = 1
archiveReason = 'Session crashed'
sessionEndReason = 'error'
}
const registerProcessHandlers = () => {
// tiann/hapi#914: SIGTERM is treated as the default reason ('Hub restart')
// because the runner is restarted by systemd as part of hub restart in
// production. If a future code path needs to distinguish "operator
// killed the host process" from "hub restart", it can call
// setArchiveReason() before the runner exits.
process.on('SIGTERM', () => {
void cleanupAndExit()
})
// Ctrl-C in a local terminal is genuine user intent — keep the
// pre-#914 label so the audit trail still shows it.
process.on('SIGINT', () => {
archiveReason = 'User terminated'
void cleanupAndExit()
})
process.on('uncaughtException', (error) => {
markCrash(error)
void cleanupAndExit(1)
})
process.on('unhandledRejection', (reason) => {
markCrash(reason)
void cleanupAndExit(1)
})
}
return {
setExitCode,
setArchiveReason,
setSessionEndReason,
markCrash,
cleanup,
cleanupAndExit,
registerProcessHandlers
}
}
export function setControlledByUser(session: ApiSessionClient, mode: 'local' | 'remote'): void {
session.updateAgentState((currentState) => ({
...currentState,
controlledByUser: mode === 'local'
}))
}
export function createModeChangeHandler(session: ApiSessionClient): (mode: 'local' | 'remote') => void {
return (mode) => {
session.sendSessionEvent({ type: 'switch', mode })
setControlledByUser(session, mode)
}
}