shraga 0.1.66 → 0.1.68
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/src/server/boot.ts +37 -2
- package/src/server/notify-owners.ts +33 -10
- package/src/server/para/feature.ts +218 -0
- package/src/server/para/streamer.ts +175 -0
- package/src/server/polls.ts +15 -2
- package/src/server/scheduler/outcome.ts +92 -0
- package/src/server/scheduler/runner.ts +98 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/boot.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAu
|
|
|
25
25
|
import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
|
|
26
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
27
27
|
import { slackFeature } from './slack/feature.ts';
|
|
28
|
+
import { paraFeature } from './para/feature.ts';
|
|
28
29
|
import { dataPath } from './paths.ts';
|
|
29
30
|
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
30
31
|
import { setBroadcaster } from './session-bus.ts';
|
|
@@ -1134,10 +1135,41 @@ if (!PASSIVE) {
|
|
|
1134
1135
|
// runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
|
|
1135
1136
|
statsSampler.start(broadcast);
|
|
1136
1137
|
registerEventRoutes(app, requireAuth);
|
|
1138
|
+
/** How long an out-of-band wake turn waits for a busy session before giving up (see runTurn). */
|
|
1139
|
+
const WAKE_LOCK_WAIT_MS = 5 * 60_000;
|
|
1137
1140
|
initPolls({
|
|
1138
1141
|
broadcast,
|
|
1139
|
-
|
|
1140
|
-
|
|
1142
|
+
// The out-of-band turn runner (polls + background-job follow-ups). It TAKES THE SESSION LOCK, and
|
|
1143
|
+
// that is load-bearing: `streamChat` never acquires one itself, so before this every guard written
|
|
1144
|
+
// against `isSessionLocked` — including background-jobs.ts's "never start a turn on top of a live
|
|
1145
|
+
// one" — was reading a lock that this path never took. Two jobs finishing minutes apart then ran
|
|
1146
|
+
// two concurrent wake turns in one session, each free to dispatch the next leg of the same
|
|
1147
|
+
// workflow: two writers on one browser instance, which is the failure these workflows are built to
|
|
1148
|
+
// prevent. Waiting (bounded) rather than failing fast, because the whole point of a wake is that
|
|
1149
|
+
// the outcome gets told: a busy session usually means another wake is mid-turn and will be done in
|
|
1150
|
+
// seconds.
|
|
1151
|
+
runTurn: async ({ prompt, sessionId, uid, userEmail }) => {
|
|
1152
|
+
const abortController = new AbortController();
|
|
1153
|
+
const deadline = Date.now() + WAKE_LOCK_WAIT_MS;
|
|
1154
|
+
while (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
1155
|
+
if (Date.now() >= deadline) {
|
|
1156
|
+
// Give up by returning NOTHING, never by throwing. A throw propagates out of wake.ts's
|
|
1157
|
+
// unguarded `await runTurn` into background-jobs' catch, which records `reported: 'failed'`
|
|
1158
|
+
// — the one delivery path with no raw fallback, so the job's outcome would reach the user in
|
|
1159
|
+
// no form at all, after wake.ts had already appended the trigger prompt (a question with no
|
|
1160
|
+
// answer in the transcript). Empty blocks are the 'no-output' contract callers already
|
|
1161
|
+
// handle: the job store then delivers its raw report instead. Degraded, but never silent.
|
|
1162
|
+
console.warn(`[wake] session ${sessionId} stayed busy for ${Math.round(WAKE_LOCK_WAIT_MS / 1000)}s — skipping the turn; the caller falls back to a raw report`);
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
1166
|
+
}
|
|
1167
|
+
try {
|
|
1168
|
+
return await consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }));
|
|
1169
|
+
} finally {
|
|
1170
|
+
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1141
1173
|
});
|
|
1142
1174
|
// Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
|
|
1143
1175
|
// initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
|
|
@@ -1172,6 +1204,9 @@ if (process.env.SHRAGA_OVERLAY) {
|
|
|
1172
1204
|
// so their routes mount ahead of the SPA fallback, identical to the overlay path.
|
|
1173
1205
|
for (const f of __reg.features ?? []) registerFeature(f);
|
|
1174
1206
|
registerFeature(slackFeature);
|
|
1207
|
+
// para-li external-agent lane — a second medium alongside Slack; both subscribe the owner-notice
|
|
1208
|
+
// bus independently, so neither affects the other.
|
|
1209
|
+
registerFeature(paraFeature);
|
|
1175
1210
|
mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
|
|
1176
1211
|
// Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
|
|
1177
1212
|
Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
|
|
@@ -10,12 +10,29 @@ import { emitEvent } from './events/bus.ts';
|
|
|
10
10
|
|
|
11
11
|
export type Owner = { name?: string; slackId: string };
|
|
12
12
|
|
|
13
|
+
/** The OWNERS env list, lowercased. THE definition of "an owner of this deployment" — every
|
|
14
|
+
* medium joins on it, each through whatever identity it happens to hold. */
|
|
15
|
+
export function ownerEmails(): string[] {
|
|
16
|
+
return (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Is this email address an owner of this deployment?
|
|
20
|
+
*
|
|
21
|
+
* Exported because a medium that is not Slack cannot use `resolveOwners`: that returns Slack ids
|
|
22
|
+
* (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. The para lane holds a
|
|
23
|
+
* shraga uid + the email of the API key that opened the link, so it joins on the email instead.
|
|
24
|
+
* An empty/unknown address is NOT an owner — the fail-closed direction, since the alternative is
|
|
25
|
+
* fanning a deploy report out to whoever happened to link a para. */
|
|
26
|
+
export function isOwnerEmail(email: string | undefined | null): boolean {
|
|
27
|
+
const e = String(email ?? '').trim().toLowerCase();
|
|
28
|
+
return !!e && ownerEmails().includes(e);
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/** Owners of THIS deployment (OWNERS env ∩ contacts that have a Slack id). */
|
|
14
32
|
export async function resolveOwners(): Promise<Owner[]> {
|
|
15
33
|
const { getAll } = await import('./contacts.ts');
|
|
16
|
-
const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
17
34
|
return getAll()
|
|
18
|
-
.filter(c => c.slackIds.length > 0 && c.emails.some(e =>
|
|
35
|
+
.filter(c => c.slackIds.length > 0 && c.emails.some(e => isOwnerEmail(e)))
|
|
19
36
|
.map(c => ({ name: c.name, slackId: c.slackIds[0] }));
|
|
20
37
|
}
|
|
21
38
|
|
|
@@ -26,16 +43,22 @@ export function senderStamp(): string {
|
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
/**
|
|
29
|
-
*
|
|
30
|
-
* keyed on the notice `kind`, so a new subsystem needs no change on the Slack
|
|
31
|
-
*
|
|
46
|
+
* Publish an owner notice on the bus. `source` is the event-bus source (used for logging/filtering
|
|
47
|
+
* only) — delivery is keyed on the notice `kind`, so a new subsystem needs no change on the Slack
|
|
48
|
+
* side.
|
|
49
|
+
*
|
|
50
|
+
* Returns NOTHING, on purpose. It used to return "was there anyone to tell", which was a truthful
|
|
51
|
+
* answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once para
|
|
52
|
+
* subscribes the same bus an empty owner list means "no Slack owner", not "nobody was notified".
|
|
53
|
+
* Rather than keep a boolean whose meaning depends on which features happen to be registered — no
|
|
54
|
+
* caller reads it (`data-sync.ts`, `self-upgrade/index.ts`) — the notice is emitted unconditionally
|
|
55
|
+
* and each subscriber decides for itself. The Slack subscriber already no-ops on an empty
|
|
56
|
+
* `owners`, so Slack behaviour is unchanged.
|
|
32
57
|
*/
|
|
33
|
-
export async function notifyOwners(source: string, text: string): Promise<
|
|
58
|
+
export async function notifyOwners(source: string, text: string): Promise<void> {
|
|
34
59
|
const owners = await resolveOwners();
|
|
60
|
+
emitEvent(source as any, { kind: 'deploy', owners, text: `${text}\n\n_from ${senderStamp()}_` });
|
|
35
61
|
if (!owners.length) {
|
|
36
|
-
console.warn(`[${source}] No owners (OWNERS env) with Slack IDs found
|
|
37
|
-
return false;
|
|
62
|
+
console.warn(`[${source}] No owners (OWNERS env) with Slack IDs found — notice emitted for non-Slack subscribers only`);
|
|
38
63
|
}
|
|
39
|
-
emitEvent(source as any, { kind: 'deploy', owners, text: `${text}\n\n_from ${senderStamp()}_` });
|
|
40
|
-
return true;
|
|
41
64
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paraFeature — the sender half of the para-li external-agent lane.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `slackFeature` exactly in shape: one `ServerFeature` that (a) mounts an ingress route and
|
|
5
|
+
* (b) subscribes the owner-notice event bus so deploy / self-upgrade / downtime notices reach the
|
|
6
|
+
* medium. Slack is untouched and the two coexist — both subscribe the same bus, neither knows about
|
|
7
|
+
* the other, and a notice is delivered to each independently.
|
|
8
|
+
*
|
|
9
|
+
* TRANSPORT. para-li POSTs one turn here and we stream the answer BACK to it over signed webhook
|
|
10
|
+
* calls (see `streamer.ts`), rather than holding this response open. para-li's caller is a Bodify
|
|
11
|
+
* trigger whose lifetime is the turn, so a multi-minute agent run held on one response body dies to
|
|
12
|
+
* a proxy idle timeout with a half-written row. Two independent requests also give the proactive
|
|
13
|
+
* lane the same transport for free.
|
|
14
|
+
*
|
|
15
|
+
* TRUST. The turn request is authenticated with an ordinary shraga API key (`POST /api/api-keys`),
|
|
16
|
+
* so reaching this route requires a credential the owner minted. The callback URL + secret arrive
|
|
17
|
+
* IN that authenticated request — para-li tells us where to answer and with what, per turn, which
|
|
18
|
+
* is what makes a rotated webhook secret take effect on the very next message with no config here.
|
|
19
|
+
*/
|
|
20
|
+
import type { ServerFeature, FeatureContext } from '../features.ts';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { subscribeEvents } from '../events/bus.ts';
|
|
24
|
+
import { streamChat } from '../claude.ts';
|
|
25
|
+
import { getMcpConfig } from '../mcp.ts';
|
|
26
|
+
import { dataPath } from '../paths.ts';
|
|
27
|
+
import {
|
|
28
|
+
appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
|
|
29
|
+
type ConvBlock,
|
|
30
|
+
} from '../sessions.ts';
|
|
31
|
+
import { validateApiKey } from '../api-keys.ts';
|
|
32
|
+
import { isOwnerEmail } from '../notify-owners.ts';
|
|
33
|
+
import { ParaStreamer, postProactive, type ParaCallback } from './streamer.ts';
|
|
34
|
+
|
|
35
|
+
interface DeployNotice { kind: 'deploy'; owners: { name?: string; slackId: string }[]; text: string }
|
|
36
|
+
|
|
37
|
+
/** Last known para conversation per connection — the proactive lane's destination.
|
|
38
|
+
*
|
|
39
|
+
* Learned from the first turn rather than configured: para-li already tells us the conv and the
|
|
40
|
+
* callback on every turn, so a second source of truth would only be a thing to drift. Persisted
|
|
41
|
+
* because a deploy notice fires right after a RESTART, which is exactly when an in-memory map is
|
|
42
|
+
* empty — the one moment the feature has to work. Lives beside `api-keys.json` in the data dir and
|
|
43
|
+
* holds the webhook secret, so it inherits that file's protection, no more and no less. */
|
|
44
|
+
const LINKS_PATH = dataPath('para-links.json');
|
|
45
|
+
/** `uid` is the shraga user whose API key opened this link. It is the OWNER of the entry — see
|
|
46
|
+
* `rememberLink`.
|
|
47
|
+
*
|
|
48
|
+
* `email` is that user's address, recorded so the PROACTIVE lane can answer "is this link's user
|
|
49
|
+
* an owner of this deployment?" — `OWNERS` is an email list, and a uid does not join to it. It is
|
|
50
|
+
* taken from `validateApiKey`, never from the request body. A link written before this field
|
|
51
|
+
* existed has no email and is therefore not an owner: it receives no notices until its next turn
|
|
52
|
+
* refreshes the entry. */
|
|
53
|
+
type Link = ParaCallback & { convId: string; at: number; uid: string; email?: string };
|
|
54
|
+
|
|
55
|
+
function loadLinks(): Record<string, Link> {
|
|
56
|
+
if (!existsSync(LINKS_PATH)) return {};
|
|
57
|
+
try { return JSON.parse(readFileSync(LINKS_PATH, 'utf-8')); } catch (err) {
|
|
58
|
+
console.warn('[para] links file unreadable, starting empty:', (err as Error).message);
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Record (or refresh) a connection's callback.
|
|
63
|
+
*
|
|
64
|
+
* ONE USER OWNS A connId. `connId` is chosen by the caller, so without this an API key for user A
|
|
65
|
+
* could claim a connId already linked by user B and re-point every future PROACTIVE notice
|
|
66
|
+
* (deploy reports, self-upgrade outcomes) at A's URL + secret. An API key is already full agent
|
|
67
|
+
* access to its own user, so this is not a privilege boundary being invented — it is the one
|
|
68
|
+
* cross-user step that access does not otherwise imply, so it is refused rather than logged. */
|
|
69
|
+
function rememberLink(link: Link): void {
|
|
70
|
+
try {
|
|
71
|
+
const all = loadLinks();
|
|
72
|
+
const prev = all[link.connId];
|
|
73
|
+
if (prev?.uid && prev.uid !== link.uid) {
|
|
74
|
+
console.warn(`[para] refusing to re-link ${link.connId}: owned by another user`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
all[link.connId] = link;
|
|
78
|
+
mkdirSync(dataPath(''), { recursive: true });
|
|
79
|
+
writeFileSync(LINKS_PATH, JSON.stringify(all, null, 2));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn('[para] could not persist link:', (err as Error).message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Run one turn, streaming into the para row. Errors settle the row visibly — the owner must never
|
|
86
|
+
* be left watching a "typing…" placeholder that will never resolve. */
|
|
87
|
+
async function runParaTurn(args: {
|
|
88
|
+
callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
|
|
89
|
+
uid: string; userEmail: string;
|
|
90
|
+
}): Promise<void> {
|
|
91
|
+
const { callback, convId, msgId, sessionId, prompt, uid, userEmail } = args;
|
|
92
|
+
const streamer = new ParaStreamer({ callback, convId, msgId });
|
|
93
|
+
const abortController = new AbortController();
|
|
94
|
+
|
|
95
|
+
// Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
|
|
96
|
+
// 'api') and this is an authenticated API caller. Widening it just to label the medium would
|
|
97
|
+
// touch recovery and status code paths for no behavioural gain.
|
|
98
|
+
if (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
99
|
+
// sessionId === convId, so this is genuinely "you sent two messages into the same thread while
|
|
100
|
+
// the first was still running". Say so rather than dropping it silently.
|
|
101
|
+
await streamer.fail('That conversation is already processing a message — wait for it to finish.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
upsertSession(sessionId, prompt, { uid, email: userEmail });
|
|
105
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'para' });
|
|
106
|
+
setRunStatus(sessionId, 'running', 'web');
|
|
107
|
+
|
|
108
|
+
const blocks: ConvBlock[] = [];
|
|
109
|
+
let text = '';
|
|
110
|
+
try {
|
|
111
|
+
for await (const ev of streamChat({
|
|
112
|
+
prompt, sessionId, uid, userEmail,
|
|
113
|
+
mcpServers: getMcpConfig(uid),
|
|
114
|
+
abortController,
|
|
115
|
+
context: { source: 'para', user: userEmail },
|
|
116
|
+
onPermissionRequest: async () => ({ allow: true }),
|
|
117
|
+
})) {
|
|
118
|
+
if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
|
|
119
|
+
else if (ev.type === 'tool_use') {
|
|
120
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
121
|
+
blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
122
|
+
streamer.feed({ type: 'tool_use', tool: ev.tool });
|
|
123
|
+
}
|
|
124
|
+
else if (ev.type === 'tool_result') blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
125
|
+
else if (ev.type === 'done') break;
|
|
126
|
+
else if (ev.type === 'error') {
|
|
127
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
128
|
+
blocks.push({ type: 'error', text: ev.message });
|
|
129
|
+
await streamer.fail(ev.message);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (text) blocks.push({ type: 'text', text });
|
|
134
|
+
await streamer.finish();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[para] turn failed:', (err as Error).message);
|
|
137
|
+
await streamer.fail((err as Error).message || 'agent error');
|
|
138
|
+
} finally {
|
|
139
|
+
// The transcript is persisted whatever happened, so the shraga UI and the next turn's context
|
|
140
|
+
// see the same history para saw.
|
|
141
|
+
if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
|
|
142
|
+
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let mounted = false;
|
|
147
|
+
let busSubscribed = false;
|
|
148
|
+
|
|
149
|
+
export const paraFeature: ServerFeature = {
|
|
150
|
+
name: 'para',
|
|
151
|
+
|
|
152
|
+
// No capability flag. `flags` is the seam's way to tell the CLIENT a surface exists, and nothing
|
|
153
|
+
// in the client gates on para — the lane is driven entirely by para.li calling in. Declaring one
|
|
154
|
+
// would be dead public surface on /api/features (slackFeature declares none for the same reason).
|
|
155
|
+
|
|
156
|
+
register(ctx: FeatureContext): void {
|
|
157
|
+
// Owner notices → the linked para conversations OF THIS DEPLOYMENT'S OWNERS. Keyed on the
|
|
158
|
+
// notice KIND, not the source, for the reason spelled out in slackFeature: self-upgrade emits
|
|
159
|
+
// under its own source and a source-gated subscriber silently dropped every one of them.
|
|
160
|
+
//
|
|
161
|
+
// WHY NOT `payload.owners`. That field is `{name?, slackId}[]` — the SLACK join, computed by
|
|
162
|
+
// `resolveOwners` as OWNERS ∩ contacts-that-have-a-Slack-id. A para link carries no Slack id,
|
|
163
|
+
// so the field is unmatchable here. Unfiltered, this loop posted every deploy / self-upgrade /
|
|
164
|
+
// data-sync report to EVERY entry in para-links.json — and any shraga user with an API key
|
|
165
|
+
// gets an entry on their first turn (`rememberLink`). The `uid` guard does not help: it stops
|
|
166
|
+
// STEALING another user's connId, not adding your own.
|
|
167
|
+
// The join that works is the one OWNERS is actually expressed in — the email of the API key
|
|
168
|
+
// that opened the link — checked with the same `isOwnerEmail` that backs `resolveOwners`.
|
|
169
|
+
if (!ctx.passive && !busSubscribed) {
|
|
170
|
+
busSubscribed = true;
|
|
171
|
+
subscribeEvents((evt) => {
|
|
172
|
+
const payload = evt.payload as DeployNotice;
|
|
173
|
+
if (payload?.kind !== 'deploy' || !payload.text) return;
|
|
174
|
+
for (const link of Object.values(loadLinks())) {
|
|
175
|
+
if (!isOwnerEmail(link.email)) continue;
|
|
176
|
+
postProactive({ url: link.url, secret: link.secret, connId: link.connId }, link.convId, payload.text)
|
|
177
|
+
.then((ok) => console.log(`[para] owner notice ${ok ? 'delivered' : 'FAILED'} → ${link.convId}`))
|
|
178
|
+
.catch((err) => console.warn('[para] owner notice failed:', (err as Error).message));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (ctx.passive || mounted) return;
|
|
184
|
+
mounted = true;
|
|
185
|
+
|
|
186
|
+
ctx.app.post('/api/para/turn', (req, res) => {
|
|
187
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
|
|
188
|
+
const caller = bearer ? validateApiKey(bearer) : null;
|
|
189
|
+
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
190
|
+
|
|
191
|
+
const { connId, convId, sessionId, msgId, prompt, callback } = req.body as {
|
|
192
|
+
connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
|
|
193
|
+
callback?: { url?: string; secret?: string };
|
|
194
|
+
};
|
|
195
|
+
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
196
|
+
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
197
|
+
try {
|
|
198
|
+
const u = new URL(callback.url);
|
|
199
|
+
// We hold the owner's credential and will POST to whatever this says, so it is validated
|
|
200
|
+
// here too rather than trusted because the request authenticated.
|
|
201
|
+
if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
|
|
202
|
+
} catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
|
|
203
|
+
|
|
204
|
+
const cb: ParaCallback = { url: callback.url, secret: callback.secret, connId };
|
|
205
|
+
rememberLink({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
|
|
206
|
+
|
|
207
|
+
// ACCEPT, then run. The answer arrives on the callback, so holding this response open would
|
|
208
|
+
// only give para-li's trigger a socket to time out on.
|
|
209
|
+
res.json({ status: 'accepted', sessionId: sessionId || convId });
|
|
210
|
+
void runParaTurn({
|
|
211
|
+
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
212
|
+
uid: caller.uid, userEmail: caller.email,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
console.log('[para] turn ingress mounted at POST /api/para/turn');
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParaStreamer — progressive delivery of one agent turn into a para-li conversation row.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT `SlackStreamer`. The brief said to reuse `mcp-slack-use/src/streamer.ts` rather than
|
|
5
|
+
* write a second streamer. Its throttling contract IS reused — buffer, `flushInterval` (300ms),
|
|
6
|
+
* `flushThreshold` (30 chars), and a serialized `flushChain` so sends never overtake each other,
|
|
7
|
+
* all mirrored here deliberately and with the same defaults. Its *transport* cannot be: every
|
|
8
|
+
* send in that class is a `slackApi(token, 'chat.appendStream'|'chat.startStream'|'chat.update')`
|
|
9
|
+
* call against Slack's three-call streaming protocol, and it lives in a vendored package in a
|
|
10
|
+
* different repo. Para's transport is one signed POST per flush carrying the accumulated text —
|
|
11
|
+
* there is no start/append/stop handshake and no ts to thread. Forking that package to
|
|
12
|
+
* parameterize the transport would be a larger, riskier change to Slack's live path than these
|
|
13
|
+
* ~60 lines, so the shared thing is the CONTRACT, not the code, and this comment is the seam.
|
|
14
|
+
*
|
|
15
|
+
* ACCUMULATE, DON'T APPEND: each flush sends the full text so far. para-li patches the message row
|
|
16
|
+
* with a whole-row `set` (its existing partial-update convention), so a dropped or reordered delta
|
|
17
|
+
* self-heals on the next flush instead of leaving a hole. That is worth more than the bytes.
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
export interface ParaCallback {
|
|
22
|
+
/** Absolute webhook URL, handed to us per-turn by para-li (never configured here). */
|
|
23
|
+
url: string;
|
|
24
|
+
/** HMAC key for THIS connection, handed over per-turn so a rotation lands on the next message. */
|
|
25
|
+
secret: string;
|
|
26
|
+
/** Connection id — inside the signed material, so a delivery is bound to its connection. */
|
|
27
|
+
connId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParaStreamerOptions {
|
|
31
|
+
callback: ParaCallback;
|
|
32
|
+
convId: string;
|
|
33
|
+
/** The row to patch. Omit for a PROACTIVE post (no preceding user turn) — see `post()`. */
|
|
34
|
+
msgId?: string;
|
|
35
|
+
flushInterval?: number;
|
|
36
|
+
flushThreshold?: number;
|
|
37
|
+
/** Show a transient inline marker per tool call, as the Slack streamer does. Default on. */
|
|
38
|
+
toolMarkers?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Signature contract, mirrored byte-for-byte in para-li's `lib/agent-conn.ts#signPayload`.
|
|
42
|
+
* The two repos are separately published, so this is duplicated on purpose; if you change one,
|
|
43
|
+
* change both — a drift here presents as a silent 401 on every delta.
|
|
44
|
+
*
|
|
45
|
+
* The DELIVERY id is in the material because para-li's replay guard dedupes on that header alone;
|
|
46
|
+
* unsigned, it would be the one field an attacker could vary freely to replay a captured delivery
|
|
47
|
+
* inside the signature window.
|
|
48
|
+
*
|
|
49
|
+
* UNESCAPED `.` — why the field boundaries cannot be shifted. There are no length prefixes, so in
|
|
50
|
+
* general `a.b.c` is ambiguous. It holds here because the receiver PINS every field but the last
|
|
51
|
+
* before it verifies: `connId` must be exactly 24 lowercase hex chars (`isConnId`, checked before
|
|
52
|
+
* the signature) and `ts` is `Number(header)` re-stringified, so it is a canonical, dot-free digit
|
|
53
|
+
* run that must also land within 300s of now. `rawBody` is trailing and can absorb nothing. That
|
|
54
|
+
* leaves `deliveryId` as the only free field, and it sits between two fixed-shape neighbours, so
|
|
55
|
+
* no (deliveryId, ts) pair can be re-cut into a different one. If either check is ever relaxed,
|
|
56
|
+
* length-prefix the material instead of relying on this. */
|
|
57
|
+
export function signPara(secret: string, connId: string, deliveryId: string, ts: number, rawBody: string): string {
|
|
58
|
+
return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One signed POST. Returns false on any non-2xx or network error, having logged it — the caller
|
|
62
|
+
* keeps streaming rather than aborting the agent's turn over a transport hiccup. */
|
|
63
|
+
export async function postPara(cb: ParaCallback, payload: object): Promise<boolean> {
|
|
64
|
+
const raw = JSON.stringify(payload);
|
|
65
|
+
const ts = Date.now();
|
|
66
|
+
// Per-DELIVERY id, not per-turn: para-li's replay guard dedupes on this, so a shared id across
|
|
67
|
+
// the deltas of one turn would drop every delta after the first. Minted here so the exact same
|
|
68
|
+
// value goes into the header AND the signature — they must not be able to diverge.
|
|
69
|
+
const delivery = randomUUID();
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(cb.url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'x-agent-conn': cb.connId,
|
|
76
|
+
'x-agent-timestamp': String(ts),
|
|
77
|
+
'x-agent-signature': signPara(cb.secret, cb.connId, delivery, ts, raw),
|
|
78
|
+
'x-agent-delivery': delivery,
|
|
79
|
+
},
|
|
80
|
+
body: raw,
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
console.warn(`[para-streamer] ${(payload as any).type} rejected: ${res.status} ${await res.text().catch(() => '')}`.slice(0, 300));
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn('[para-streamer] delivery failed:', (err as Error).message);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ParaStreamer {
|
|
94
|
+
private buffer = '';
|
|
95
|
+
private fullText = '';
|
|
96
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
97
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
98
|
+
private aborted = false;
|
|
99
|
+
private afterTool = false;
|
|
100
|
+
|
|
101
|
+
private readonly flushInterval: number;
|
|
102
|
+
private readonly flushThreshold: number;
|
|
103
|
+
private readonly toolMarkers: boolean;
|
|
104
|
+
|
|
105
|
+
constructor(private readonly opts: ParaStreamerOptions) {
|
|
106
|
+
this.flushInterval = opts.flushInterval ?? 300;
|
|
107
|
+
this.flushThreshold = opts.flushThreshold ?? 30;
|
|
108
|
+
this.toolMarkers = opts.toolMarkers ?? true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
feed(ev: { type: string; text?: string; tool?: string }): void {
|
|
112
|
+
if (this.aborted || !this.opts.msgId) return;
|
|
113
|
+
|
|
114
|
+
if (ev.type === 'text_delta' && ev.text) {
|
|
115
|
+
if (this.afterTool) { this.fullText += '\n'; this.afterTool = false; }
|
|
116
|
+
this.buffer += ev.text;
|
|
117
|
+
this.fullText += ev.text;
|
|
118
|
+
if (this.buffer.length >= this.flushThreshold) this.enqueueFlush();
|
|
119
|
+
else this.scheduleTimer();
|
|
120
|
+
} else if (ev.type === 'tool_use' && ev.tool && this.toolMarkers) {
|
|
121
|
+
// In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
|
|
122
|
+
// (para-li writes the whole row), so the marker disappears on its own.
|
|
123
|
+
this.afterTool = true;
|
|
124
|
+
this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
|
|
125
|
+
this.enqueueFlush();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Settle the row with the final text. Returns the text actually sent. */
|
|
130
|
+
async finish(): Promise<string> {
|
|
131
|
+
this.clearTimer();
|
|
132
|
+
this.buffer = '';
|
|
133
|
+
await this.flushChain;
|
|
134
|
+
if (this.aborted || !this.opts.msgId) return this.fullText;
|
|
135
|
+
const text = this.fullText.trim() || '(no output)';
|
|
136
|
+
await postPara(this.opts.callback, { type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text });
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Settle the row as a visible failure. The owner sees WHY, in the thread, not only in a log. */
|
|
141
|
+
async fail(message: string): Promise<void> {
|
|
142
|
+
this.aborted = true;
|
|
143
|
+
this.clearTimer();
|
|
144
|
+
await this.flushChain;
|
|
145
|
+
if (!this.opts.msgId) return;
|
|
146
|
+
await postPara(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private enqueueFlush(): void {
|
|
150
|
+
this.clearTimer();
|
|
151
|
+
if (!this.fullText) return;
|
|
152
|
+
this.buffer = '';
|
|
153
|
+
const snapshot = this.fullText;
|
|
154
|
+
// Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
|
|
155
|
+
// visibly rewinds mid-stream.
|
|
156
|
+
this.flushChain = this.flushChain
|
|
157
|
+
.then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot }); })
|
|
158
|
+
.catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private scheduleTimer(): void {
|
|
162
|
+
this.clearTimer();
|
|
163
|
+
this.timer = setTimeout(() => this.enqueueFlush(), this.flushInterval);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private clearTimer(): void {
|
|
167
|
+
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** PROACTIVE post — a scheduled run, a deploy notice, a downtime report. No preceding user turn,
|
|
172
|
+
* so there is no row to patch: para-li mints one. Same signed transport, same fence. */
|
|
173
|
+
export function postProactive(cb: ParaCallback, convId: string, text: string): Promise<boolean> {
|
|
174
|
+
return postPara(cb, { type: 'post', convId, text });
|
|
175
|
+
}
|
package/src/server/polls.ts
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { dataPath } from './paths.ts';
|
|
10
10
|
import { getSession } from './sessions.ts';
|
|
11
11
|
import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
|
|
12
|
-
import { initWake, wakeSession, type TurnRunner } from './wake.ts';
|
|
12
|
+
import { initWake, wakeSession, deliverToSession, type TurnRunner } from './wake.ts';
|
|
13
13
|
|
|
14
14
|
const PREFIX = '[polls]';
|
|
15
15
|
|
|
@@ -155,5 +155,18 @@ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
|
|
|
155
155
|
const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
|
|
156
156
|
const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
// The return value is NOT decoration: a wake that could not run a turn ('no-output' — no runner
|
|
159
|
+
// wired, or the session stayed busy past the wake lock's wait) leaves this poll CLOSED, already
|
|
160
|
+
// re-rendered as closed in Slack, and the transcript holding a `[Poll result]` prompt with no
|
|
161
|
+
// answer. The tally would then be lost for good (the record is pruned after 7 days). So when no
|
|
162
|
+
// turn ran, deliver the lines we already built — the same raw-report fallback the background-job
|
|
163
|
+
// caller makes. `unreadFallback` above cannot cover this: wake.ts returns before it consults it.
|
|
164
|
+
const outcome = await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
|
|
165
|
+
if (outcome !== 'woke') {
|
|
166
|
+
console.warn(`${PREFIX} wake for ${p.pollId} returned '${outcome}' — delivering the tally as plain text instead`);
|
|
167
|
+
await deliverToSession({
|
|
168
|
+
sessionId: p.sessionId, uid: p.uid, title: p.title,
|
|
169
|
+
text: `${headline} — "${p.title}" (${voterCount(p)} participant(s)).\n${lines}`,
|
|
170
|
+
}).catch((e) => console.error(`${PREFIX} raw tally deliver failed:`, (e as Error)?.message));
|
|
171
|
+
}
|
|
159
172
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A scheduled run's DECLARED outcome — the run saying what actually happened, instead of the
|
|
2
|
+
// scheduler inferring success from "the agent's turn returned".
|
|
3
|
+
//
|
|
4
|
+
// The bug this exists for: a prompt run whose real work failed (or never started) still records
|
|
5
|
+
// `ok`, because the only thing measured was that the turn came back. On 2026-08-28 the 15:30 social
|
|
6
|
+
// run's scout died, nothing was delivered, the run stored `ok`, and the (enabled) failure notifier
|
|
7
|
+
// stayed silent all day — it is event-driven on `status: 'error'` and was never given one.
|
|
8
|
+
//
|
|
9
|
+
// It gets worse with background jobs (server/background-jobs.ts): there, ending the turn early is
|
|
10
|
+
// the CORRECT behaviour — the work outlives it and the job store wakes the session when it exits.
|
|
11
|
+
// So "the turn returned" stops being even a weak proxy for the run's outcome.
|
|
12
|
+
//
|
|
13
|
+
// Shape: one JSON file per run session, written by the run itself (any tool that can write a file —
|
|
14
|
+
// no new tool surface, nothing to plumb through the engine), read by runner.ts when the turn ends.
|
|
15
|
+
// { "status": "ok" } → the run delivered
|
|
16
|
+
// { "status": "error", "error": "…" } → it did not; this fires the notifier
|
|
17
|
+
// { "status": "pending", "deadline": <epoch ms|ISO> } → work is still in flight; the run stays
|
|
18
|
+
// open until a terminal declaration lands,
|
|
19
|
+
// and FAILS if the deadline passes first
|
|
20
|
+
// Absent file ⇒ unchanged legacy behaviour (turn returned = ok), so no existing schedule changes.
|
|
21
|
+
// Deliberately domain-free: it knows nothing about what the run was doing.
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { dataPath } from '../paths.ts';
|
|
25
|
+
|
|
26
|
+
/** Cap on how long a `pending` run may hold the window open, whatever deadline it asked for. */
|
|
27
|
+
export const MAX_PENDING_MS = 6 * 60 * 60_000;
|
|
28
|
+
/** Used when a `pending` declaration names no deadline. */
|
|
29
|
+
export const DEFAULT_PENDING_MS = 60 * 60_000;
|
|
30
|
+
|
|
31
|
+
export interface DeclaredOutcome {
|
|
32
|
+
status: 'ok' | 'error' | 'pending';
|
|
33
|
+
error?: string;
|
|
34
|
+
/** For `pending`: when the run gives up and is recorded as failed. Epoch ms or ISO-8601. */
|
|
35
|
+
deadline?: number | string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dir = (): string => { const d = dataPath('scheduler', 'outcomes'); mkdirSync(d, { recursive: true }); return d; };
|
|
39
|
+
/** Session ids are server-minted (`sched-<id>-<ts>`), but this value reaches `path.join` and `rmSync`
|
|
40
|
+
* — so it is validated rather than trusted. A `../` in there would delete outside the outcomes dir. */
|
|
41
|
+
const safeId = (sessionId: string): string => {
|
|
42
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.startsWith('.')) throw new Error(`unsafe session id for an outcome file: ${sessionId}`);
|
|
43
|
+
return sessionId;
|
|
44
|
+
};
|
|
45
|
+
export const outcomeFile = (sessionId: string): string => path.join(dir(), `${safeId(sessionId)}.json`);
|
|
46
|
+
|
|
47
|
+
export function readOutcome(sessionId: string): DeclaredOutcome | null {
|
|
48
|
+
let raw: string;
|
|
49
|
+
try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); } catch { return null; }
|
|
50
|
+
let o: DeclaredOutcome;
|
|
51
|
+
// A malformed declaration is not "no declaration": the run tried to say something. Surfacing it
|
|
52
|
+
// as an error beats silently falling back to the optimistic default this module exists to remove.
|
|
53
|
+
// But a plain `Write` is not atomic, so a read can also land MID-write — that is a torn read, not
|
|
54
|
+
// a malformed declaration, and failing a run for it would be the same class of lie in reverse.
|
|
55
|
+
// Re-read once after a beat before believing it (the prompt also asks for write-temp-then-rename).
|
|
56
|
+
try { o = JSON.parse(raw) as DeclaredOutcome; }
|
|
57
|
+
catch {
|
|
58
|
+
try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); o = JSON.parse(raw) as DeclaredOutcome; }
|
|
59
|
+
catch { return { status: 'error', error: `run outcome file is not valid JSON: ${raw.slice(0, 200)}` }; }
|
|
60
|
+
}
|
|
61
|
+
if (o?.status !== 'ok' && o?.status !== 'error' && o?.status !== 'pending')
|
|
62
|
+
return { status: 'error', error: `run outcome file has an invalid status: ${JSON.stringify(o?.status)}` };
|
|
63
|
+
return o;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clearOutcome(sessionId: string): void {
|
|
67
|
+
try { rmSync(outcomeFile(sessionId)); } catch { /* nothing to clear */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test/host seam — writes a declaration the way a run's own file write would. */
|
|
71
|
+
export function writeOutcome(sessionId: string, o: DeclaredOutcome): void {
|
|
72
|
+
writeFileSync(outcomeFile(sessionId), JSON.stringify(o));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Absolute epoch ms a `pending` run expires at, clamped to MAX_PENDING_MS. */
|
|
76
|
+
export function pendingDeadline(o: DeclaredOutcome, now: number): number {
|
|
77
|
+
const raw = typeof o.deadline === 'string' ? Date.parse(o.deadline) : o.deadline;
|
|
78
|
+
const asked = Number.isFinite(raw) ? (raw as number) : now + DEFAULT_PENDING_MS;
|
|
79
|
+
return Math.min(Math.max(asked, now), now + MAX_PENDING_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The contract, appended to a scheduled prompt run so a run can report itself truthfully. */
|
|
83
|
+
export function outcomePrompt(sessionId: string): string {
|
|
84
|
+
return `# Reporting this run's outcome
|
|
85
|
+
This is a scheduled run. Unless you say otherwise, it is recorded as SUCCESSFUL the moment your turn returns — which is a lie whenever the work failed, was skipped, or is still in flight. Correct that by writing this file:
|
|
86
|
+
\`${outcomeFile(sessionId)}\`
|
|
87
|
+
- \`{"status":"ok"}\` — the run delivered what it was for.
|
|
88
|
+
- \`{"status":"error","error":"<what went wrong>"}\` — it did not. This is what raises the failure alert; write it for a leg that never ran, a worker that died, or work you could not finish.
|
|
89
|
+
- \`{"status":"pending","deadline":"<ISO-8601>"}\` — work you started outlives this turn (e.g. a background job). The run stays open and NOT successful until you write a terminal status from a later turn; if the deadline passes with no terminal status, the run is recorded as failed automatically.
|
|
90
|
+
Write it atomically — write a temp file next to it and \`mv\` it into place — so a reader can never catch it half-written.
|
|
91
|
+
Declare \`pending\` BEFORE you end a turn that leaves work running, and re-declare \`ok\`/\`error\` from the turn that sees it finish. Never declare \`ok\` for a run that did not deliver.`;
|
|
92
|
+
}
|
|
@@ -6,7 +6,8 @@ import { streamChat, type PermissionHandler } from '../claude.ts';
|
|
|
6
6
|
import { getMcpConfig } from '../mcp.ts';
|
|
7
7
|
import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
|
|
8
8
|
import type { Schedule, ScheduleRunSummary } from './types.ts';
|
|
9
|
-
import { updateRunLockPid, clearRunningMarker } from './storage.ts';
|
|
9
|
+
import { updateRunLockPid, clearRunningMarker, loadSchedules } from './storage.ts';
|
|
10
|
+
import { readOutcome, clearOutcome, pendingDeadline, outcomePrompt, MAX_PENDING_MS } from './outcome.ts';
|
|
10
11
|
import { addUnread } from '../unread.ts';
|
|
11
12
|
|
|
12
13
|
export interface RunContext {
|
|
@@ -87,6 +88,70 @@ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve)
|
|
|
87
88
|
signal?.addEventListener('abort', done, { once: true });
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
/** How often a `pending` run is re-checked while it waits for its terminal declaration. */
|
|
92
|
+
const OUTCOME_POLL_MS = 10_000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve what the run itself declared, once its turn has ended.
|
|
96
|
+
*
|
|
97
|
+
* Returns null when the run declared nothing — that is the legacy path and stays exactly as it was
|
|
98
|
+
* (turn returned ⇒ ok). A `pending` declaration keeps the run open until a terminal one lands; each
|
|
99
|
+
* fresh `pending` extends the wait (leg 2 re-declaring after leg 1 finished), bounded absolutely by
|
|
100
|
+
* MAX_PENDING_MS from the first one so an agent cannot extend forever. Silence past the deadline is
|
|
101
|
+
* a FAILURE — that is the whole point: a run that never came back must alert, not read as success.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* The schedule's CURRENT next fire time, read live from disk each time it is needed.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately not the caller's snapshot: `fireDue()` starts the run in its first loop and only
|
|
107
|
+
* advances `nextRun` in its second, and `startRun` deep-copies the schedule BEFORE that advance —
|
|
108
|
+
* so the snapshot this run was handed still carries the window it is running FOR (already in the
|
|
109
|
+
* past), which would make the ceiling below `Infinity` and inert. The engine persists the advanced
|
|
110
|
+
* value (`saveSchedules` at the end of `fireDue`), so disk is the source of truth here. Reading it
|
|
111
|
+
* per poll also picks up an edit made while the run waits.
|
|
112
|
+
*/
|
|
113
|
+
function liveNextWindow(scheduleId: string): number | undefined {
|
|
114
|
+
try { return loadSchedules().find((s) => s.id === scheduleId)?.nextRun; }
|
|
115
|
+
catch { return undefined; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveDeclaredOutcome(
|
|
119
|
+
sessionId: string,
|
|
120
|
+
ac: AbortController,
|
|
121
|
+
/** Schedule whose next window caps the wait — looked up live, never from the run's snapshot. */
|
|
122
|
+
scheduleId: string,
|
|
123
|
+
): Promise<{ status: Exclude<ScheduleRunSummary['status'], 'running'>; error?: string } | null> {
|
|
124
|
+
let declared = readOutcome(sessionId);
|
|
125
|
+
if (!declared) return null;
|
|
126
|
+
// Waiting happens INSIDE the run promise, and engine.startRun only does `state.running.delete()`
|
|
127
|
+
// when that promise settles — so a pending run keeps its schedule marked running, and the next
|
|
128
|
+
// fire of the same schedule is QUEUED behind it rather than run on time. Verified in
|
|
129
|
+
// engine.ts (`state.running.delete` sits in `.finally`, and `fire()` queues when `state.running`
|
|
130
|
+
// has the id). For a 3×-daily schedule a multi-hour pending wait would therefore eat the next
|
|
131
|
+
// slot. So the wait ends at the next window at the latest: the run is then recorded as failed
|
|
132
|
+
// (the notifier fires) and the new window starts clean and on time.
|
|
133
|
+
const windowStop = (): number => {
|
|
134
|
+
const next = liveNextWindow(scheduleId);
|
|
135
|
+
return next && next > Date.now() ? next - 60_000 : Infinity;
|
|
136
|
+
};
|
|
137
|
+
const absoluteStop = Date.now() + MAX_PENDING_MS;
|
|
138
|
+
while (declared?.status === 'pending' && !ac.signal.aborted) {
|
|
139
|
+
const stop = windowStop();
|
|
140
|
+
const deadline = Math.min(pendingDeadline(declared, Date.now()), absoluteStop, stop);
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
const why = deadline === stop ? 'its next scheduled window arrived first' : `deadline ${new Date(deadline).toISOString()}`;
|
|
143
|
+
return { status: 'error', error: `Run declared itself still in flight and never reported a terminal outcome (${why}).` };
|
|
144
|
+
}
|
|
145
|
+
await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()), ac.signal);
|
|
146
|
+
declared = readOutcome(sessionId);
|
|
147
|
+
}
|
|
148
|
+
if (ac.signal.aborted) return { status: 'aborted' };
|
|
149
|
+
if (!declared) return { status: 'error', error: 'Run outcome declaration disappeared before it reported a terminal state.' };
|
|
150
|
+
return declared.status === 'error'
|
|
151
|
+
? { status: 'error', error: declared.error ? `Run reported failure: ${declared.error}` : 'Run reported failure with no detail.' }
|
|
152
|
+
: { status: 'ok' };
|
|
153
|
+
}
|
|
154
|
+
|
|
90
155
|
function formatEventBlock(e: EventContext): string {
|
|
91
156
|
let body: string;
|
|
92
157
|
try { body = JSON.stringify(e.payload, null, 2); } catch { body = String(e.payload); }
|
|
@@ -140,6 +205,17 @@ export async function runSchedule(
|
|
|
140
205
|
if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
|
|
141
206
|
prompt = base;
|
|
142
207
|
}
|
|
208
|
+
// Tell the run how to report its own truthful outcome (scheduler/outcome.ts). Prompt tasks only:
|
|
209
|
+
// a `bash` task's permission handler allows nothing but the task's own command, so such a run
|
|
210
|
+
// could not write the file even if it wanted to — its exit code is already the truth there.
|
|
211
|
+
if (task.kind === 'prompt') {
|
|
212
|
+
// Cleared on RESUME too: a resume reuses the interrupted run's session id, so a declaration left
|
|
213
|
+
// by the attempt that crashed would be adopted as this attempt's verdict. The contract is
|
|
214
|
+
// re-stated for the same reason — the resumed turn must be able to declare for itself.
|
|
215
|
+
clearOutcome(sessionId);
|
|
216
|
+
prompt = `${prompt}\n\n---\n${outcomePrompt(sessionId)}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
// task.engine/task.model ride the same prompt-directive channel users type by hand —
|
|
144
220
|
// parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
|
|
145
221
|
// choice into the saved prompt, so the session UI shows what the schedule actually requested.
|
|
@@ -322,6 +398,27 @@ export async function runSchedule(
|
|
|
322
398
|
updateScheduledSessionStatus(sessionId, status);
|
|
323
399
|
}
|
|
324
400
|
|
|
401
|
+
// The run's OWN verdict beats "the turn returned" — see scheduler/outcome.ts. Deliberately after
|
|
402
|
+
// the finally: the session lock is released by now, so a run that declared `pending` can be closed
|
|
403
|
+
// by a later turn in this session (a background job's wake, a follow-up message) while we wait.
|
|
404
|
+
if (status === 'ok' && task.kind === 'prompt') {
|
|
405
|
+
const declared = await resolveDeclaredOutcome(sessionId, abortController, schedule.id);
|
|
406
|
+
if (declared) {
|
|
407
|
+
status = declared.status;
|
|
408
|
+
error = declared.error;
|
|
409
|
+
updateScheduledSessionStatus(sessionId, status);
|
|
410
|
+
if (status !== 'ok') {
|
|
411
|
+
appendMessage(sessionId, {
|
|
412
|
+
id: crypto.randomUUID(),
|
|
413
|
+
role: 'assistant',
|
|
414
|
+
blocks: [{ type: 'error', text: error ?? `Run reported status ${status}` }],
|
|
415
|
+
});
|
|
416
|
+
onEvent({ type: 'session_messages_changed', sessionId });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
clearOutcome(sessionId);
|
|
420
|
+
}
|
|
421
|
+
|
|
325
422
|
const preview = assistantText.slice(0, 120) || (status === 'ok' ? 'Schedule completed' : `Schedule ${status}`);
|
|
326
423
|
addUnread(schedule.createdBy.uid, sessionId, preview, 'schedule', schedule.name);
|
|
327
424
|
|