ftown-bridge 0.19.0 → 0.19.2
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/dist/agent-commands.d.ts +3 -17
- package/dist/agent-commands.js +8 -90
- package/dist/agent-commands.js.map +1 -1
- package/dist/bridge-auth.d.ts +21 -0
- package/dist/bridge-auth.js +48 -0
- package/dist/bridge-auth.js.map +1 -0
- package/dist/centrifugo-client.js +39 -0
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +15 -0
- package/dist/command-rpc.js +249 -0
- package/dist/command-rpc.js.map +1 -0
- package/dist/create-ftown-session.d.ts +43 -0
- package/dist/create-ftown-session.js +115 -9
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-sessions-cli.js +74 -4
- package/dist/ftown-sessions-cli.js.map +1 -1
- package/dist/harness-cli.js +1 -13
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-installer.js +4 -2
- package/dist/harness-installer.js.map +1 -1
- package/dist/harness-registry.d.ts +189 -0
- package/dist/harness-registry.js +218 -0
- package/dist/harness-registry.js.map +1 -0
- package/dist/index.js +72 -721
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-cli.js +12 -3
- package/dist/install-ftown-cli.js.map +1 -1
- package/dist/local-api-server.d.ts +14 -13
- package/dist/local-api-server.js +180 -424
- package/dist/local-api-server.js.map +1 -1
- package/dist/loop-controller.d.ts +87 -0
- package/dist/loop-controller.js +133 -0
- package/dist/loop-controller.js.map +1 -0
- package/dist/loop-run-store.js +2 -8
- package/dist/loop-run-store.js.map +1 -1
- package/dist/loop-validation.d.ts +1 -0
- package/dist/loop-validation.js +3 -8
- package/dist/loop-validation.js.map +1 -1
- package/dist/mail-delivery.d.ts +114 -0
- package/dist/mail-delivery.js +315 -0
- package/dist/mail-delivery.js.map +1 -0
- package/dist/session-controller.d.ts +107 -0
- package/dist/session-controller.js +141 -0
- package/dist/session-controller.js.map +1 -0
- package/dist/session-ids.d.ts +19 -0
- package/dist/session-ids.js +72 -0
- package/dist/session-ids.js.map +1 -0
- package/dist/session-resurrection.d.ts +24 -0
- package/dist/session-resurrection.js +128 -0
- package/dist/session-resurrection.js.map +1 -1
- package/dist/spawn-stagger.d.ts +22 -0
- package/dist/spawn-stagger.js +34 -0
- package/dist/spawn-stagger.js.map +1 -0
- package/dist/terminal-pump.d.ts +61 -0
- package/dist/terminal-pump.js +193 -0
- package/dist/terminal-pump.js.map +1 -0
- package/dist/types.d.ts +29 -4
- package/dist/usage-collector.d.ts +42 -0
- package/dist/usage-collector.js +317 -0
- package/dist/usage-collector.js.map +1 -0
- package/dist/wire-types.d.ts +124 -0
- package/dist/wire-types.js +28 -0
- package/dist/wire-types.js.map +1 -0
- package/dist/workflow-runner-cli.js +2 -1
- package/dist/workflow-runner-cli.js.map +1 -1
- package/dist/workflow-runner.d.ts +2 -1
- package/dist/workflow-runner.js.map +1 -1
- package/package.json +1 -1
- package/skills/factory/SKILL.md +167 -0
- package/skills/factory/factory-template/bin/dispatch.py +472 -0
- package/skills/factory/factory-template/factory.yaml +36 -0
- package/skills/factory/factory-template/skills/_protocol.md +85 -0
- package/skills/factory/factory-template/skills/design.md +259 -0
- package/skills/factory/factory-template/skills/digest.md +78 -0
- package/skills/factory/factory-template/skills/groom.md +243 -0
- package/skills/factory/factory-template/skills/implement.md +316 -0
- package/skills/factory/factory-template/skills/pr.md +220 -0
- package/skills/factory/factory-template/skills/qa.md +226 -0
- package/skills/factory/factory-template/skills/review.md +231 -0
- package/skills/factory/factory-template/skills/triage.md +157 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createFtownSession, relaunchFtownSession, } from './create-ftown-session.js';
|
|
2
|
+
export class SessionController {
|
|
3
|
+
deps;
|
|
4
|
+
constructor(deps) {
|
|
5
|
+
this.deps = deps;
|
|
6
|
+
}
|
|
7
|
+
require(dep, name) {
|
|
8
|
+
if (dep === undefined) {
|
|
9
|
+
throw new Error(`SessionController: ${name} dependency not wired`);
|
|
10
|
+
}
|
|
11
|
+
return dep;
|
|
12
|
+
}
|
|
13
|
+
/** Create + launch a session. Throws (see class doc) — adapters map errors. */
|
|
14
|
+
async create(input) {
|
|
15
|
+
const factory = this.require(this.deps.sessionFactory, 'sessionFactory');
|
|
16
|
+
return createFtownSession(factory, input);
|
|
17
|
+
}
|
|
18
|
+
/** Stop a running session and persist/publish its completed state. */
|
|
19
|
+
async stop(sessionId) {
|
|
20
|
+
const stopped = this.deps.runner.stop(sessionId);
|
|
21
|
+
if (stopped) {
|
|
22
|
+
this.require(this.deps.publishSyntheticStop, 'publishSyntheticStop')(sessionId, 'stopped');
|
|
23
|
+
await this.require(this.deps.withSessionWrite, 'withSessionWrite')(sessionId, async () => {
|
|
24
|
+
const session = await this.deps.store.loadSession(sessionId);
|
|
25
|
+
if (session) {
|
|
26
|
+
session.status = 'completed';
|
|
27
|
+
session.updatedAt = new Date().toISOString();
|
|
28
|
+
await this.deps.store.saveSession(session);
|
|
29
|
+
await this.deps.publishSessionUpdate(session);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
this.require(this.deps.unregisterSession, 'unregisterSession')(sessionId);
|
|
33
|
+
}
|
|
34
|
+
return { stopped };
|
|
35
|
+
}
|
|
36
|
+
list() {
|
|
37
|
+
return this.deps.store.listSessions();
|
|
38
|
+
}
|
|
39
|
+
get(sessionId) {
|
|
40
|
+
return this.deps.store.loadSession(sessionId);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-session token/cost usage. Returns the persisted snapshot when
|
|
44
|
+
* present; otherwise collects on demand (covers live sessions and sessions
|
|
45
|
+
* that finished before usage collection existed). An on-demand result is
|
|
46
|
+
* persisted + published only when the session is terminal — a live
|
|
47
|
+
* session's numbers are still moving, so caching them would go stale.
|
|
48
|
+
*/
|
|
49
|
+
async usage(sessionId) {
|
|
50
|
+
const session = await this.deps.store.loadSession(sessionId);
|
|
51
|
+
if (!session) {
|
|
52
|
+
return { ok: false, code: 'not_found', message: 'Session not found' };
|
|
53
|
+
}
|
|
54
|
+
if (session.usage) {
|
|
55
|
+
return { ok: true, usage: session.usage };
|
|
56
|
+
}
|
|
57
|
+
const collect = this.deps.collectUsage;
|
|
58
|
+
if (!collect) {
|
|
59
|
+
return { ok: true, usage: null };
|
|
60
|
+
}
|
|
61
|
+
const usage = await collect(session);
|
|
62
|
+
if (usage && (session.status === 'completed' || session.status === 'error')) {
|
|
63
|
+
session.usage = usage;
|
|
64
|
+
session.updatedAt = new Date().toISOString();
|
|
65
|
+
await this.deps.store.saveSession(session);
|
|
66
|
+
await this.deps.publishSessionUpdate(session);
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, usage };
|
|
69
|
+
}
|
|
70
|
+
/** Re-run a finished/dead session's stored command verbatim. */
|
|
71
|
+
async retry(sessionId) {
|
|
72
|
+
const factory = this.require(this.deps.sessionFactory, 'sessionFactory');
|
|
73
|
+
const existingSession = await this.deps.store.loadSession(sessionId);
|
|
74
|
+
if (!existingSession) {
|
|
75
|
+
return { ok: false, code: 'not_found', message: 'Session not found' };
|
|
76
|
+
}
|
|
77
|
+
if (existingSession.status === 'running') {
|
|
78
|
+
return { ok: false, code: 'conflict', message: 'Session is already running' };
|
|
79
|
+
}
|
|
80
|
+
if (!existingSession.command) {
|
|
81
|
+
return { ok: false, code: 'invalid', message: 'Session has no command (created before v0.2.0)' };
|
|
82
|
+
}
|
|
83
|
+
await relaunchFtownSession(factory, existingSession, 'retry');
|
|
84
|
+
return { ok: true, session: existingSession };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Rename and/or reparent in ONE load-mutate-save-publish pass (the HTTP
|
|
88
|
+
* PATCH route updates both fields atomically; the RPC adapter calls with a
|
|
89
|
+
* single field). Reparenting flattens to the proposed parent's root so
|
|
90
|
+
* session trees stay one level deep.
|
|
91
|
+
*/
|
|
92
|
+
async update(sessionId, input) {
|
|
93
|
+
const session = await this.deps.store.loadSession(sessionId);
|
|
94
|
+
if (!session) {
|
|
95
|
+
return { ok: false, code: 'not_found', message: 'Session not found' };
|
|
96
|
+
}
|
|
97
|
+
if (input.name !== undefined) {
|
|
98
|
+
if (typeof input.name !== 'string' || !input.name) {
|
|
99
|
+
return { ok: false, code: 'invalid', message: 'Invalid name' };
|
|
100
|
+
}
|
|
101
|
+
session.name = input.name;
|
|
102
|
+
}
|
|
103
|
+
if (input.parent) {
|
|
104
|
+
const raw = input.parent.value;
|
|
105
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
106
|
+
session.parentSessionId = undefined;
|
|
107
|
+
}
|
|
108
|
+
else if (typeof raw !== 'string') {
|
|
109
|
+
return { ok: false, code: 'invalid', message: 'Invalid parentSessionId' };
|
|
110
|
+
}
|
|
111
|
+
else if (raw === session.id) {
|
|
112
|
+
return { ok: false, code: 'invalid', message: 'Session cannot be its own parent' };
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const proposed = await this.deps.store.loadSession(raw);
|
|
116
|
+
if (!proposed) {
|
|
117
|
+
return { ok: false, code: 'invalid', message: 'Parent session not found' };
|
|
118
|
+
}
|
|
119
|
+
session.parentSessionId = proposed.parentSessionId ?? proposed.id;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
session.updatedAt = new Date().toISOString();
|
|
123
|
+
await this.deps.store.saveSession(session);
|
|
124
|
+
await this.deps.publishSessionUpdate(session);
|
|
125
|
+
return { ok: true, session };
|
|
126
|
+
}
|
|
127
|
+
/** Remove (archive + delete + publish removed). removed=false when nothing
|
|
128
|
+
* was removed — unknown id, or the onlyIfFinished guard rejected it. */
|
|
129
|
+
async remove(sessionId, options) {
|
|
130
|
+
const removed = await this.deps.removeSession(sessionId, options);
|
|
131
|
+
return { removed: removed !== null };
|
|
132
|
+
}
|
|
133
|
+
/** Flush pending output, wipe the persisted log, drop the live buffer. */
|
|
134
|
+
async clearTerminal(sessionId) {
|
|
135
|
+
this.require(this.deps.flushTerminalBuffer, 'flushTerminalBuffer')(sessionId);
|
|
136
|
+
await this.deps.store.clearTerminalLog(sessionId);
|
|
137
|
+
this.require(this.deps.destroyTerminal, 'destroyTerminal')(sessionId);
|
|
138
|
+
return { cleared: true };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=session-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-controller.js","sourceRoot":"","sources":["../src/session-controller.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HookEvent } from './local-api-server.js';
|
|
2
|
+
import type { SessionStore } from './session-store.js';
|
|
3
|
+
import type { Session } from './types.js';
|
|
4
|
+
export interface AgentSessionIdPersisterDeps {
|
|
5
|
+
store: Pick<SessionStore, 'loadSession' | 'saveSession'>;
|
|
6
|
+
publishSessionUpdate: (session: Session) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Persists agent-native session ids (Claude/Codex session_id, Cursor
|
|
10
|
+
* conversation_id) from hook events onto the stored session record, with an
|
|
11
|
+
* in-memory cache of the last persisted ids to skip disk reads on the hot
|
|
12
|
+
* hook path.
|
|
13
|
+
*/
|
|
14
|
+
export declare class AgentSessionIdPersister {
|
|
15
|
+
private readonly deps;
|
|
16
|
+
private readonly cache;
|
|
17
|
+
constructor(deps: AgentSessionIdPersisterDeps);
|
|
18
|
+
persist(hookEvent: HookEvent): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persists agent-native session ids (Claude/Codex session_id, Cursor
|
|
3
|
+
* conversation_id) from hook events onto the stored session record, with an
|
|
4
|
+
* in-memory cache of the last persisted ids to skip disk reads on the hot
|
|
5
|
+
* hook path.
|
|
6
|
+
*/
|
|
7
|
+
export class AgentSessionIdPersister {
|
|
8
|
+
deps;
|
|
9
|
+
cache = new Map();
|
|
10
|
+
constructor(deps) {
|
|
11
|
+
this.deps = deps;
|
|
12
|
+
}
|
|
13
|
+
async persist(hookEvent) {
|
|
14
|
+
// Workspace-fallback attribution may come from a foreign agent the user
|
|
15
|
+
// ran manually in the same directory; never persist its ids.
|
|
16
|
+
if (hookEvent.source === 'workspace')
|
|
17
|
+
return;
|
|
18
|
+
const rawAgentId = hookEvent.data['session_id'];
|
|
19
|
+
const rawCursorId = hookEvent.data['conversation_id'];
|
|
20
|
+
// Claude Code AND Codex hooks carry session_id (which field it lands in
|
|
21
|
+
// depends on the session's shellType); Cursor hooks carry conversation_id.
|
|
22
|
+
const agentId = typeof rawAgentId === 'string' && rawAgentId ? rawAgentId : undefined;
|
|
23
|
+
const cursorId = typeof rawCursorId === 'string' && rawCursorId ? rawCursorId : undefined;
|
|
24
|
+
if (!agentId && !cursorId)
|
|
25
|
+
return;
|
|
26
|
+
const cached = this.cache.get(hookEvent.sessionId);
|
|
27
|
+
if (cached
|
|
28
|
+
&& (!agentId || (cached.isCodex ? cached.codex === agentId : cached.claude === agentId))
|
|
29
|
+
&& (!cursorId || cached.cursor === cursorId)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const session = await this.deps.store.loadSession(hookEvent.sessionId);
|
|
33
|
+
if (!session)
|
|
34
|
+
return;
|
|
35
|
+
const isCodex = session.shellType === 'codex';
|
|
36
|
+
let changed = false;
|
|
37
|
+
if (agentId) {
|
|
38
|
+
if (isCodex && session.codexSessionId !== agentId) {
|
|
39
|
+
session.codexSessionId = agentId;
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
else if (!isCodex && session.claudeSessionId !== agentId) {
|
|
43
|
+
session.claudeSessionId = agentId;
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (cursorId && session.cursorSessionId !== cursorId) {
|
|
48
|
+
session.cursorSessionId = cursorId;
|
|
49
|
+
changed = true;
|
|
50
|
+
}
|
|
51
|
+
if (!changed) {
|
|
52
|
+
this.cache.set(hookEvent.sessionId, {
|
|
53
|
+
claude: session.claudeSessionId,
|
|
54
|
+
cursor: session.cursorSessionId,
|
|
55
|
+
codex: session.codexSessionId,
|
|
56
|
+
isCodex,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
session.updatedAt = new Date().toISOString();
|
|
61
|
+
await this.deps.store.saveSession(session);
|
|
62
|
+
// Cache only after a successful save, so a failed persist is retried.
|
|
63
|
+
this.cache.set(hookEvent.sessionId, {
|
|
64
|
+
claude: session.claudeSessionId,
|
|
65
|
+
cursor: session.cursorSessionId,
|
|
66
|
+
codex: session.codexSessionId,
|
|
67
|
+
isCodex,
|
|
68
|
+
});
|
|
69
|
+
await this.deps.publishSessionUpdate(session);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=session-ids.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-ids.js","sourceRoot":"","sources":["../src/session-ids.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,2 +1,26 @@
|
|
|
1
1
|
import type { Session } from './types.js';
|
|
2
|
+
import type { SessionStore } from './session-store.js';
|
|
3
|
+
import type { ProcessRunner } from './claude-runner.js';
|
|
4
|
+
import { type CreateFtownSessionDeps } from './create-ftown-session.js';
|
|
2
5
|
export declare function shouldResurrectStoredSession(session: Session): boolean;
|
|
6
|
+
export interface SessionResurrectionDeps {
|
|
7
|
+
store: SessionStore;
|
|
8
|
+
runner: ProcessRunner;
|
|
9
|
+
bridgeId: string;
|
|
10
|
+
sessionFactoryDeps: CreateFtownSessionDeps;
|
|
11
|
+
publishSessionUpdate: (session: Session) => Promise<void>;
|
|
12
|
+
wireTerminalInput: (sessionId: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resurrection engine: on bridge start, sessions left in a live state by a
|
|
16
|
+
* previous bridge are reattached (tmux), respawned (resume), or marked as
|
|
17
|
+
* error. Loop-run sessions are deferred to the loop scheduler.
|
|
18
|
+
*/
|
|
19
|
+
export declare class SessionResurrection {
|
|
20
|
+
private readonly deps;
|
|
21
|
+
private started;
|
|
22
|
+
constructor(deps: SessionResurrectionDeps);
|
|
23
|
+
private markSessionDead;
|
|
24
|
+
private resurrectSession;
|
|
25
|
+
resurrectSessions(): Promise<void>;
|
|
26
|
+
}
|
|
@@ -1,6 +1,134 @@
|
|
|
1
|
+
import { canResumeStoredSession, findMissingProviderAuth, relaunchFtownSession, } from './create-ftown-session.js';
|
|
2
|
+
import { loadProviderEnv } from './provider-env-store.js';
|
|
3
|
+
import { registerSessionWorkspace } from './session-registry.js';
|
|
4
|
+
import { isTmuxAvailable, killTmuxSession, listFtownTmuxSessions } from './tmux.js';
|
|
1
5
|
export function shouldResurrectStoredSession(session) {
|
|
2
6
|
if (session.status !== 'running' && session.status !== 'pending')
|
|
3
7
|
return false;
|
|
4
8
|
return !session.loopId;
|
|
5
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Resurrection engine: on bridge start, sessions left in a live state by a
|
|
12
|
+
* previous bridge are reattached (tmux), respawned (resume), or marked as
|
|
13
|
+
* error. Loop-run sessions are deferred to the loop scheduler.
|
|
14
|
+
*/
|
|
15
|
+
export class SessionResurrection {
|
|
16
|
+
deps;
|
|
17
|
+
started = false;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
}
|
|
21
|
+
async markSessionDead(session, reason) {
|
|
22
|
+
session.status = 'error';
|
|
23
|
+
session.errorReason = reason;
|
|
24
|
+
session.updatedAt = new Date().toISOString();
|
|
25
|
+
await this.deps.store.saveSession(session);
|
|
26
|
+
await this.deps.publishSessionUpdate(session);
|
|
27
|
+
console.log(`[Bridge] Marked stale session ${session.id} as error`);
|
|
28
|
+
}
|
|
29
|
+
async resurrectSession(sessionId) {
|
|
30
|
+
const { store, runner, bridgeId, sessionFactoryDeps } = this.deps;
|
|
31
|
+
// Commands run concurrently with the resurrection loop; act on fresh
|
|
32
|
+
// state so a session stopped or removed mid-loop is not respawned.
|
|
33
|
+
const session = await store.loadSession(sessionId);
|
|
34
|
+
if (!session || (session.status !== 'running' && session.status !== 'pending'))
|
|
35
|
+
return;
|
|
36
|
+
if (runner.isRunning(session.id))
|
|
37
|
+
return;
|
|
38
|
+
if (runner.reattach(session.id, {
|
|
39
|
+
workingDir: session.workingDir,
|
|
40
|
+
parentSessionId: session.parentSessionId,
|
|
41
|
+
})) {
|
|
42
|
+
session.status = 'running';
|
|
43
|
+
session.bridgeId = bridgeId;
|
|
44
|
+
session.errorReason = undefined;
|
|
45
|
+
session.updatedAt = new Date().toISOString();
|
|
46
|
+
await store.saveSession(session);
|
|
47
|
+
await this.deps.publishSessionUpdate(session);
|
|
48
|
+
this.deps.wireTerminalInput(session.id);
|
|
49
|
+
registerSessionWorkspace(session.id, session.workingDir);
|
|
50
|
+
console.log(`[Bridge] Resurrected session ${session.id} via tmux reattach`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (canResumeStoredSession(session)) {
|
|
54
|
+
const miss = findMissingProviderAuth(session.shellType, {
|
|
55
|
+
processEnv: process.env,
|
|
56
|
+
storeEnv: loadProviderEnv(),
|
|
57
|
+
});
|
|
58
|
+
if (miss) {
|
|
59
|
+
await this.markSessionDead(session, miss.message);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// The custom-vs-rebuilt resume-command decision lives in
|
|
63
|
+
// deriveRelaunchCommand, inside relaunchFtownSession.
|
|
64
|
+
const resumeCommand = await relaunchFtownSession(sessionFactoryDeps, session, 'resume');
|
|
65
|
+
console.log(`[Bridge] Resurrected session ${session.id} via resume respawn: ${resumeCommand}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await this.markSessionDead(session);
|
|
69
|
+
}
|
|
70
|
+
async resurrectSessions() {
|
|
71
|
+
if (this.started)
|
|
72
|
+
return;
|
|
73
|
+
this.started = true;
|
|
74
|
+
const { store } = this.deps;
|
|
75
|
+
const sessions = await store.listSessions();
|
|
76
|
+
// Reap tmux sessions for OUR dead store records (removed records, failed
|
|
77
|
+
// kills) before resurrection re-creates any. Archived tombstones count as
|
|
78
|
+
// dead too: remove_session deletes the store record, so a failed tmux
|
|
79
|
+
// kill would otherwise leave a permanently invisible live agent. Tmux
|
|
80
|
+
// sessions with ids we have no record of are left alone — they may
|
|
81
|
+
// belong to another bridge running on this machine.
|
|
82
|
+
if (isTmuxAvailable()) {
|
|
83
|
+
const archived = await store.listArchived();
|
|
84
|
+
const deadIds = new Set([
|
|
85
|
+
...sessions
|
|
86
|
+
.filter((s) => s.status !== 'running' && s.status !== 'pending')
|
|
87
|
+
.map((s) => s.id),
|
|
88
|
+
...archived.map((a) => a.id),
|
|
89
|
+
]);
|
|
90
|
+
// A live store record outranks any tombstone for the same id (crash
|
|
91
|
+
// between archive and delete leaves both): never reap resurrectables.
|
|
92
|
+
for (const s of sessions) {
|
|
93
|
+
if (s.status === 'running' || s.status === 'pending') {
|
|
94
|
+
deadIds.delete(s.id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const tmuxId of listFtownTmuxSessions()) {
|
|
98
|
+
if (deadIds.has(tmuxId)) {
|
|
99
|
+
console.log(`[Bridge] Killing tmux session for dead session ${tmuxId}`);
|
|
100
|
+
await killTmuxSession(tmuxId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let deferredLoopRuns = 0;
|
|
105
|
+
for (const session of sessions) {
|
|
106
|
+
if (session.loopId) {
|
|
107
|
+
if (session.status !== 'running' && session.status !== 'pending')
|
|
108
|
+
continue;
|
|
109
|
+
deferredLoopRuns += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!shouldResurrectStoredSession(session))
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
await this.resurrectSession(session.id);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error(`[Bridge] Failed to resurrect session ${session.id}:`, err);
|
|
119
|
+
try {
|
|
120
|
+
const current = await store.loadSession(session.id);
|
|
121
|
+
if (current)
|
|
122
|
+
await this.markSessionDead(current);
|
|
123
|
+
}
|
|
124
|
+
catch (markErr) {
|
|
125
|
+
console.error(`[Bridge] Failed to mark session ${session.id} as error:`, markErr);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (deferredLoopRuns > 0) {
|
|
130
|
+
console.log(`[Bridge] Deferred ${deferredLoopRuns} loop-run session(s) to the loop scheduler`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
6
134
|
//# sourceMappingURL=session-resurrection.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-resurrection.js","sourceRoot":"","sources":["../src/session-resurrection.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"session-resurrection.js","sourceRoot":"","sources":["../src/session-resurrection.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key spawn stagger: serializes concurrent callers of the same key so
|
|
3
|
+
* their spawns land at least `minGapMs` apart.
|
|
4
|
+
*
|
|
5
|
+
* Why: some harness CLIs (cursor-agent) refresh an auth token on startup with
|
|
6
|
+
* a read-modify-write against the macOS Keychain. Two or more concurrent
|
|
7
|
+
* startups race that write (pure last-writer-wins) and corrupt the stored
|
|
8
|
+
* credential. Spacing spawns by a small gap fully eliminates the race.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm: a monotonic slot reservation per key. Each caller SYNCHRONOUSLY
|
|
11
|
+
* (before any await — this is what makes it lock-free under the single-threaded
|
|
12
|
+
* event loop) reserves `slot = max(now, lastSlot + minGapMs)`, records it as
|
|
13
|
+
* the new lastSlot, then sleeps until its slot. N concurrent callers therefore
|
|
14
|
+
* serialize at exactly `minGapMs` intervals; a caller arriving after the queue
|
|
15
|
+
* has drained (now >= lastSlot + minGapMs) gets slot = now and no delay.
|
|
16
|
+
*/
|
|
17
|
+
export declare function staggerSpawn(key: string, minGapMs: number, opts?: {
|
|
18
|
+
now?: () => number;
|
|
19
|
+
sleep?: (ms: number) => Promise<void>;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
/** Clears all reserved slots. Test seam only. */
|
|
22
|
+
export declare function resetSpawnStaggerForTest(): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key spawn stagger: serializes concurrent callers of the same key so
|
|
3
|
+
* their spawns land at least `minGapMs` apart.
|
|
4
|
+
*
|
|
5
|
+
* Why: some harness CLIs (cursor-agent) refresh an auth token on startup with
|
|
6
|
+
* a read-modify-write against the macOS Keychain. Two or more concurrent
|
|
7
|
+
* startups race that write (pure last-writer-wins) and corrupt the stored
|
|
8
|
+
* credential. Spacing spawns by a small gap fully eliminates the race.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm: a monotonic slot reservation per key. Each caller SYNCHRONOUSLY
|
|
11
|
+
* (before any await — this is what makes it lock-free under the single-threaded
|
|
12
|
+
* event loop) reserves `slot = max(now, lastSlot + minGapMs)`, records it as
|
|
13
|
+
* the new lastSlot, then sleeps until its slot. N concurrent callers therefore
|
|
14
|
+
* serialize at exactly `minGapMs` intervals; a caller arriving after the queue
|
|
15
|
+
* has drained (now >= lastSlot + minGapMs) gets slot = now and no delay.
|
|
16
|
+
*/
|
|
17
|
+
const lastSlotByKey = new Map();
|
|
18
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
export function staggerSpawn(key, minGapMs, opts) {
|
|
20
|
+
const now = opts?.now ?? Date.now;
|
|
21
|
+
const sleep = opts?.sleep ?? defaultSleep;
|
|
22
|
+
// Reserve the slot synchronously — no await may happen before this point.
|
|
23
|
+
const arrival = now();
|
|
24
|
+
const lastSlot = lastSlotByKey.get(key);
|
|
25
|
+
const slot = lastSlot === undefined ? arrival : Math.max(arrival, lastSlot + minGapMs);
|
|
26
|
+
lastSlotByKey.set(key, slot);
|
|
27
|
+
const waitMs = slot - arrival;
|
|
28
|
+
return waitMs > 0 ? sleep(waitMs) : Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
/** Clears all reserved slots. Test seam only. */
|
|
31
|
+
export function resetSpawnStaggerForTest() {
|
|
32
|
+
lastSlotByKey.clear();
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=spawn-stagger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spawn-stagger.js","sourceRoot":"","sources":["../src/spawn-stagger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEhD,MAAM,YAAY,GAAG,CAAC,EAAU,EAAiB,EAAE,CACjD,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAEpD,MAAM,UAAU,YAAY,CAC1B,GAAW,EACX,QAAgB,EAChB,IAAoE;IAEpE,MAAM,GAAG,GAAG,IAAI,EAAE,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,IAAI,YAAY,CAAC;IAE1C,0EAA0E;IAC1E,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;IACtB,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,GAAG,QAAQ,CAAC,CAAC;IACvF,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE7B,MAAM,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC;IAC9B,OAAO,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;AACxD,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,wBAAwB;IACtC,aAAa,CAAC,KAAK,EAAE,CAAC;AACxB,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ProcessRunner } from './claude-runner.js';
|
|
2
|
+
import type { SessionStore } from './session-store.js';
|
|
3
|
+
import type { TerminalManager } from './terminal-manager.js';
|
|
4
|
+
import type { Session, SessionUsage } from './types.js';
|
|
5
|
+
export type SyntheticStopReason = 'complete' | 'error' | 'stopped';
|
|
6
|
+
export interface TerminalPumpDeps {
|
|
7
|
+
store: Pick<SessionStore, 'appendTerminalData' | 'loadSession' | 'saveSession' | 'loadTerminalLog'>;
|
|
8
|
+
terminalManager: Pick<TerminalManager, 'write' | 'destroy'>;
|
|
9
|
+
publishTerminalData: (sessionId: string, data: string) => void;
|
|
10
|
+
publishSessionUpdate: (session: Session) => Promise<void>;
|
|
11
|
+
publishHookEvent: (sessionId: string, event: Record<string, unknown>) => Promise<void>;
|
|
12
|
+
unregisterSession: (sessionId: string) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Optional (injectable for tests): extract token/cost usage from the
|
|
15
|
+
* session's harness-native transcript. Fired-and-forgotten after the
|
|
16
|
+
* terminal status persist on complete/error; a null result never clobbers
|
|
17
|
+
* previously persisted usage.
|
|
18
|
+
*/
|
|
19
|
+
collectUsage?: (session: Session) => Promise<SessionUsage | null>;
|
|
20
|
+
/** Test seams; production uses the defaults. */
|
|
21
|
+
flushIntervalMs?: number;
|
|
22
|
+
maxBufferBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Terminal output pump: buffers runner PTY output per session (coalescing into
|
|
26
|
+
* ≤16ms / ≤32KB flushes to the store + transports), and owns the runner
|
|
27
|
+
* lifecycle handlers ('complete'/'error') that persist final loop-run/session
|
|
28
|
+
* statuses — the loop scheduler reads the statuses this module produces.
|
|
29
|
+
*/
|
|
30
|
+
export declare class TerminalPump {
|
|
31
|
+
private readonly deps;
|
|
32
|
+
private readonly outputBuffers;
|
|
33
|
+
private readonly flushTimers;
|
|
34
|
+
private readonly sessionWrites;
|
|
35
|
+
private readonly flushIntervalMs;
|
|
36
|
+
private readonly maxBufferBytes;
|
|
37
|
+
constructor(deps: TerminalPumpDeps);
|
|
38
|
+
/** Wire the pump onto the runner's data/lifecycle events. */
|
|
39
|
+
attach(runner: Pick<ProcessRunner, 'on'>): void;
|
|
40
|
+
handleData(sessionId: string, data: string): void;
|
|
41
|
+
flush(sessionId: string): void;
|
|
42
|
+
withSessionWrite(sessionId: string, task: () => Promise<void>): Promise<void>;
|
|
43
|
+
publishSyntheticStop(sessionId: string, reason: SyntheticStopReason): void;
|
|
44
|
+
/**
|
|
45
|
+
* Fire-and-forget usage collection after a terminal status persist. Runs
|
|
46
|
+
* OUTSIDE the caller's write (the collect can take seconds on huge
|
|
47
|
+
* transcripts); the persist itself is serialized via withSessionWrite.
|
|
48
|
+
* Guard: a null collection result never clobbers previously saved usage.
|
|
49
|
+
*/
|
|
50
|
+
private collectAndPersistUsage;
|
|
51
|
+
private handleComplete;
|
|
52
|
+
/**
|
|
53
|
+
* `<error message> — <recent terminal tail>` (ANSI-stripped, whitespace
|
|
54
|
+
* collapsed, capped at ~400 chars) so a startup crash's cause is readable
|
|
55
|
+
* straight off the session record. The tail comes from the flushed terminal
|
|
56
|
+
* log; flush() fires its append without awaiting, so the chunk buffered at
|
|
57
|
+
* error time is re-appended if it hasn't landed on disk yet.
|
|
58
|
+
*/
|
|
59
|
+
private buildErrorReason;
|
|
60
|
+
private handleError;
|
|
61
|
+
}
|