pikiloom 0.4.68 → 0.4.69
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/package.json +1 -1
- package/packages/kernel/dist/contracts/ports.d.ts +7 -0
- package/packages/kernel/dist/drivers/codex.js +19 -2
- package/packages/kernel/dist/drivers/rpc.d.ts +7 -0
- package/packages/kernel/dist/drivers/rpc.js +8 -0
- package/packages/kernel/dist/ports/defaults.d.ts +12 -0
- package/packages/kernel/dist/ports/defaults.js +55 -0
- package/packages/kernel/dist/runtime/hub.js +4 -0
- package/packages/kernel/dist/runtime/loom.js +12 -1
package/package.json
CHANGED
|
@@ -14,6 +14,8 @@ export interface CoreSessionRecord {
|
|
|
14
14
|
effort?: string | null;
|
|
15
15
|
runState?: 'running' | 'completed' | 'incomplete';
|
|
16
16
|
runDetail?: string | null;
|
|
17
|
+
runPid?: number | null;
|
|
18
|
+
runStartedAt?: number | null;
|
|
17
19
|
}
|
|
18
20
|
export interface SessionStore {
|
|
19
21
|
ensure(agent: string, opts: {
|
|
@@ -30,6 +32,11 @@ export interface SessionStore {
|
|
|
30
32
|
limit?: number;
|
|
31
33
|
}): Promise<CoreSessionRecord[]>;
|
|
32
34
|
recordResult(agent: string, sessionId: string, result: DriverResult): Promise<void>;
|
|
35
|
+
markRunning?(agent: string, sessionId: string, owner: {
|
|
36
|
+
pid: number;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
|
|
33
40
|
appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
34
41
|
history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
35
42
|
}
|
|
@@ -263,8 +263,25 @@ export class CodexDriver {
|
|
|
263
263
|
let steerRegistered = false;
|
|
264
264
|
if (!srv.start())
|
|
265
265
|
return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
|
|
266
|
-
let
|
|
267
|
-
|
|
266
|
+
let settled = false;
|
|
267
|
+
let resolveTurn = () => { };
|
|
268
|
+
const turnDone = new Promise((res) => { resolveTurn = res; });
|
|
269
|
+
// Idempotent: turnDone can be settled from three racing sources (turn/completed, abort,
|
|
270
|
+
// process death); the first wins and the rest are no-ops.
|
|
271
|
+
const settle = () => { if (settled)
|
|
272
|
+
return; settled = true; resolveTurn(); };
|
|
273
|
+
// If the app-server process dies WITHOUT a turn/completed (crash, kill, disconnect), the
|
|
274
|
+
// `await turnDone` below would otherwise hang forever — run() never resolves, recordResult
|
|
275
|
+
// never fires, and the session is stranded runState:"running" in the orchestrator even though
|
|
276
|
+
// the codex process is already dead. Settle it as a failed turn so the orchestrator can
|
|
277
|
+
// finalize it (mirrors the claude driver's child.on('close') settle).
|
|
278
|
+
srv.onClose(() => {
|
|
279
|
+
if (settled)
|
|
280
|
+
return;
|
|
281
|
+
state.error = state.error || srv.stderrText().trim().split('\n').pop() || 'codex app-server exited before the turn completed';
|
|
282
|
+
state.status = 'error';
|
|
283
|
+
settle();
|
|
284
|
+
});
|
|
268
285
|
// On abort: gracefully interrupt the running turn, then settle turnDone OURSELVES. A bare
|
|
269
286
|
// srv.kill() (SIGTERM) never produces a turn/completed notification, so without this explicit
|
|
270
287
|
// settle() the `await turnDone` below hangs forever — run() never resolves and the task stays
|
|
@@ -25,12 +25,19 @@ export declare class StdioRpcClient {
|
|
|
25
25
|
private readonly pending;
|
|
26
26
|
private notifyCb?;
|
|
27
27
|
private requestCb?;
|
|
28
|
+
private closeCb?;
|
|
28
29
|
private readonly stderrTail;
|
|
29
30
|
private readonly label;
|
|
30
31
|
constructor(opts: StdioRpcOptions);
|
|
31
32
|
onNotification(cb: (method: string, params: any) => void): void;
|
|
32
33
|
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
33
34
|
onRequest(cb: (method: string, params: any, id: number | string) => any | Promise<any>): void;
|
|
35
|
+
/**
|
|
36
|
+
* Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
|
|
37
|
+
* (not a pending request) MUST use this to settle its turn — otherwise a process that dies
|
|
38
|
+
* without emitting its completion notification leaves the turn hanging forever.
|
|
39
|
+
*/
|
|
40
|
+
onClose(cb: () => void): void;
|
|
34
41
|
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
35
42
|
stderrText(): string;
|
|
36
43
|
start(): boolean;
|
|
@@ -16,6 +16,7 @@ export class StdioRpcClient {
|
|
|
16
16
|
pending = new Map();
|
|
17
17
|
notifyCb;
|
|
18
18
|
requestCb;
|
|
19
|
+
closeCb;
|
|
19
20
|
stderrTail = [];
|
|
20
21
|
label;
|
|
21
22
|
constructor(opts) {
|
|
@@ -25,6 +26,12 @@ export class StdioRpcClient {
|
|
|
25
26
|
onNotification(cb) { this.notifyCb = cb; }
|
|
26
27
|
/** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
|
|
27
28
|
onRequest(cb) { this.requestCb = cb; }
|
|
29
|
+
/**
|
|
30
|
+
* Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
|
|
31
|
+
* (not a pending request) MUST use this to settle its turn — otherwise a process that dies
|
|
32
|
+
* without emitting its completion notification leaves the turn hanging forever.
|
|
33
|
+
*/
|
|
34
|
+
onClose(cb) { this.closeCb = cb; }
|
|
28
35
|
/** Rolling tail of the process' stderr — startup/crash diagnostics. */
|
|
29
36
|
stderrText() { return this.stderrTail.join('\n'); }
|
|
30
37
|
start() {
|
|
@@ -55,6 +62,7 @@ export class StdioRpcClient {
|
|
|
55
62
|
for (const cb of this.pending.values())
|
|
56
63
|
cb({ error: { message: `${this.label} exited` } });
|
|
57
64
|
this.pending.clear();
|
|
65
|
+
this.closeCb?.();
|
|
58
66
|
});
|
|
59
67
|
this.proc.on('error', () => { });
|
|
60
68
|
return true;
|
|
@@ -25,9 +25,21 @@ export declare class FsSessionStore implements SessionStore {
|
|
|
25
25
|
text?: string;
|
|
26
26
|
sessionId?: string | null;
|
|
27
27
|
}): Promise<void>;
|
|
28
|
+
markRunning(agent: string, sessionId: string, owner: {
|
|
29
|
+
pid: number;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
|
|
28
33
|
appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
29
34
|
history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
30
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
38
|
+
* no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
|
|
39
|
+
* The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
|
|
40
|
+
* that might still be running.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
31
43
|
export declare function defaultBaseDir(appNamespace: string): string;
|
|
32
44
|
export declare class NullModelResolver implements ModelResolver {
|
|
33
45
|
resolve(): Promise<null>;
|
|
@@ -74,6 +74,7 @@ export class FsSessionStore {
|
|
|
74
74
|
return;
|
|
75
75
|
rec.runState = result.ok ? 'completed' : 'incomplete';
|
|
76
76
|
rec.runDetail = result.error ?? null;
|
|
77
|
+
rec.runPid = null; // turn is over — drop the owner so reconciliation ignores it
|
|
77
78
|
if (result.sessionId)
|
|
78
79
|
rec.nativeSessionId = result.sessionId;
|
|
79
80
|
if (result.text && !rec.title)
|
|
@@ -82,6 +83,43 @@ export class FsSessionStore {
|
|
|
82
83
|
rec.preview = result.text.replace(/\s+/g, ' ').trim().slice(0, 200) || rec.preview || null;
|
|
83
84
|
await this.save(rec);
|
|
84
85
|
}
|
|
86
|
+
async markRunning(agent, sessionId, owner) {
|
|
87
|
+
const rec = await this.get(agent, sessionId);
|
|
88
|
+
if (!rec)
|
|
89
|
+
return;
|
|
90
|
+
rec.runState = 'running';
|
|
91
|
+
rec.runDetail = null;
|
|
92
|
+
rec.runPid = owner.pid;
|
|
93
|
+
rec.runStartedAt = owner.startedAt;
|
|
94
|
+
await this.save(rec);
|
|
95
|
+
}
|
|
96
|
+
async reconcileRunning(isAlive) {
|
|
97
|
+
let agents = [];
|
|
98
|
+
try {
|
|
99
|
+
agents = fs.readdirSync(this.baseDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
let repaired = 0;
|
|
105
|
+
for (const agent of agents) {
|
|
106
|
+
for (const rec of await this.list(agent)) {
|
|
107
|
+
if (rec.runState !== 'running')
|
|
108
|
+
continue;
|
|
109
|
+
// Only reap a KNOWN-dead owner. No pid (legacy record) or a live pid (possibly a turn
|
|
110
|
+
// running in another process against this shared store) is left alone — never clobber a
|
|
111
|
+
// turn that might still be live elsewhere.
|
|
112
|
+
if (typeof rec.runPid !== 'number' || isAlive(rec.runPid))
|
|
113
|
+
continue;
|
|
114
|
+
rec.runState = 'incomplete';
|
|
115
|
+
rec.runDetail = rec.runDetail || 'interrupted: owner process exited';
|
|
116
|
+
rec.runPid = null;
|
|
117
|
+
await this.save(rec);
|
|
118
|
+
repaired++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return repaired;
|
|
122
|
+
}
|
|
85
123
|
async appendTurn(agent, sessionId, turn) {
|
|
86
124
|
const p = this.turnsPath(agent, sessionId);
|
|
87
125
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -108,6 +146,23 @@ export class FsSessionStore {
|
|
|
108
146
|
return out;
|
|
109
147
|
}
|
|
110
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
151
|
+
* no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
|
|
152
|
+
* The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
|
|
153
|
+
* that might still be running.
|
|
154
|
+
*/
|
|
155
|
+
export function isProcessAlive(pid) {
|
|
156
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return e?.code === 'EPERM';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
111
166
|
export function defaultBaseDir(appNamespace) {
|
|
112
167
|
return path.join(os.homedir(), `.${appNamespace}`, 'sessions');
|
|
113
168
|
}
|
|
@@ -94,6 +94,10 @@ export class Hub {
|
|
|
94
94
|
};
|
|
95
95
|
this.sessions.set(sessionKey, entry);
|
|
96
96
|
this.emitSessionsChanged();
|
|
97
|
+
// Stamp the persisted record as running under THIS process, so runState is authoritative for
|
|
98
|
+
// the whole turn (new AND resumed) and a crash strands an owner pid that boot reconciliation
|
|
99
|
+
// can reap. Best-effort: a store without markRunning just won't be reconcilable.
|
|
100
|
+
await this.deps.sessionStore.markRunning?.(agent, sessionId, { pid: process.pid, startedAt: Date.now() }).catch(() => { });
|
|
97
101
|
// Resolve injection / tools / resume-target at run time so a promoted turn sees the
|
|
98
102
|
// prior turn's native session id and any refreshed credentials/tools.
|
|
99
103
|
const rec = await this.deps.sessionStore.get(agent, sessionId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, } from '../ports/defaults.js';
|
|
1
|
+
import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, isProcessAlive, } from '../ports/defaults.js';
|
|
2
2
|
import { Hub } from './hub.js';
|
|
3
3
|
import { PtyBridge } from './pty.js';
|
|
4
4
|
import { attachTui } from './tui.js';
|
|
@@ -59,6 +59,17 @@ export function createLoom(config = {}) {
|
|
|
59
59
|
if (started)
|
|
60
60
|
return;
|
|
61
61
|
started = true;
|
|
62
|
+
// Repair sessions stranded at runState:'running' by a previous process that died mid-turn
|
|
63
|
+
// (crash / kill / power loss) before its driver could settle. Safe under a shared store:
|
|
64
|
+
// only records whose owner pid is dead are reaped (see reconcileRunning). Best-effort.
|
|
65
|
+
try {
|
|
66
|
+
const repaired = await sessionStore.reconcileRunning?.(isProcessAlive);
|
|
67
|
+
if (repaired)
|
|
68
|
+
log(`[loom] reconciled ${repaired} orphaned running session(s) at startup`);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
log(`[loom] startup reconcile failed: ${e?.message || e}`);
|
|
72
|
+
}
|
|
62
73
|
for (const t of surfaces) {
|
|
63
74
|
await t.start(hub, { openTui }); // hub = Lane S (LoomIO); openTui = Lane R
|
|
64
75
|
log(`[loom] terminal started: ${t.id}`);
|