shraga 0.1.40 → 0.1.41

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
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",
@@ -78,6 +78,18 @@ export function start(broadcast: Broadcast): void {
78
78
  s.lastRun.error = 'interrupted by server restart';
79
79
  }
80
80
  if (!s.enabled) { s.nextRun = undefined; continue; }
81
+ // A one-off that already ran must never be armed again. It is normally DELETED on success,
82
+ // but schedules.json is a whole-file JSON array shared by this process and by humans over
83
+ // git — a conflicting rebase, or data-sync's union-merge conflict resolver, can bring the
84
+ // deleted entry back. If its `at` is still in the future the past-due guard below cannot
85
+ // see it, and a completed campaign send would go out twice. Refuse it here instead.
86
+ const ranBefore = oneShotAlreadyRan(s);
87
+ if (ranBefore) {
88
+ console.warn(`[scheduler] ⚠️ refusing to re-arm completed one-off ${s.id} ("${s.name}") — ${ranBefore}. Disabled; delete it or create a new schedule.`);
89
+ s.enabled = false;
90
+ s.nextRun = undefined;
91
+ continue;
92
+ }
81
93
  const next = computeNextRun(s.trigger);
82
94
  if (next === null) {
83
95
  if (s.trigger.kind === 'once') s.enabled = false;
@@ -164,6 +176,10 @@ export function getSchedule(id: string): Schedule | undefined {
164
176
  export function upsertSchedule(s: Schedule): { ok: true; schedule: Schedule } | { ok: false; error: string } {
165
177
  const err = validateTrigger(s.trigger);
166
178
  if (err) return { ok: false, error: err };
179
+ // Same guard as boot: re-adding a spent one-off under its old id is a resurrection, not an
180
+ // edit. A genuinely new job gets a new id.
181
+ const ranBefore = oneShotAlreadyRan(s);
182
+ if (ranBefore) return { ok: false, error: `This one-off schedule already ran — ${ranBefore}. Create a new schedule instead of re-adding this one.` };
167
183
  if (s.onMissed !== undefined && !MISSED_POLICIES.includes(s.onMissed)) {
168
184
  return { ok: false, error: `Invalid onMissed "${s.onMissed}" (expected ${MISSED_POLICIES.join(' | ')})` };
169
185
  }
@@ -361,6 +377,31 @@ export function resumeRun(scheduleId: string, sessionId: string, prompt: string)
361
377
 
362
378
  // ── Internals ───────────────────────────────────────────────────────────────
363
379
 
380
+ /**
381
+ * Evidence that a `once` schedule has ALREADY had its single run — i.e. this entry is a
382
+ * resurrection of one the engine deleted on success, not a new job. Returns a human-readable
383
+ * reason, or null when the schedule is not a spent one-off.
384
+ *
385
+ * Two independent witnesses, because neither survives everything:
386
+ * - the completion marker (data/scheduler/completions/<id>.json), which is per-id, gitignored
387
+ * and local to the firing instance — so a git merge can never carry it away, but a rebuilt
388
+ * data dir loses it;
389
+ * - the entry's own `lastRun.status === 'ok'`, which travels inside schedules.json — so it
390
+ * survives a wiped data dir, but only if the resurrected copy is from after that run.
391
+ */
392
+ function oneShotAlreadyRan(s: Schedule): string | null {
393
+ if (s.trigger.kind !== 'once') return null;
394
+ const marker = readCompletionMarker(s.id);
395
+ // `completedAt` is the last SUCCESSFUL completion, and 0 when there has never been one. A
396
+ // marker is also written at fire time (`markRunStarted`) and on failure
397
+ // (`recordAttemptOutcome`), so its mere existence proves only an attempt. A future-dated
398
+ // one-off whose manual test-run errored has a marker but has never sent — retiring it would
399
+ // cancel a send that never happened, and report it as completed at epoch 0.
400
+ if (marker && marker.completedAt > 0) return `it completed at ${new Date(marker.completedAt).toISOString()} (${marker.triggeredBy})`;
401
+ if (s.lastRun?.status === 'ok') return `its own lastRun records a successful run at ${new Date(s.lastRun.at).toISOString()}`;
402
+ return null;
403
+ }
404
+
364
405
  const MISSED_POLICIES: MissedPolicy[] = ['run', 'skip', 'offer'];
365
406
 
366
407
  function refuse(reason: RunRefusal, message: string): RunOutcome {
@@ -43,6 +43,13 @@ function buildBlocks(id: string, questions: AskQuestion[]): unknown[] {
43
43
  return blocks;
44
44
  }
45
45
 
46
+ /** Replace a question message's blocks with a final state, so its buttons stop looking live. */
47
+ async function retire(p: Pending, text: string) {
48
+ if (!p.messageTs) return;
49
+ await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] }, p.useUserToken)
50
+ .catch((err) => console.error(`${PREFIX} retire failed:`, (err as Error)?.message));
51
+ }
52
+
46
53
  /** Build a QuestionHandler bound to a Slack channel/thread for the current turn. */
47
54
  export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
48
55
  return async (id, questions) => {
@@ -55,7 +62,12 @@ export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
55
62
  console.log(`${PREFIX} posted question ${id} (${questions.length}q) to ${ctx.channel}`);
56
63
  return new Promise<QuestionAnswers | null>((resolve) => {
57
64
  const timer = setTimeout(() => {
58
- if (pending.delete(id)) { console.log(`${PREFIX} ${id} timed out`); resolve(null); }
65
+ const p = pending.get(id);
66
+ if (!p) return;
67
+ pending.delete(id);
68
+ console.log(`${PREFIX} ${id} timed out`);
69
+ void retire(p, '⌛ This question expired — I went ahead without an answer. Reply in the thread to steer me.');
70
+ resolve(null);
59
71
  }, TTL_MS);
60
72
  pending.set(id, { ...ctx, resolve, questions, messageTs: res.ts, timer });
61
73
  });
@@ -98,14 +110,22 @@ export async function handleSlackInteraction(payload: any): Promise<boolean> {
98
110
  if (!submit) return false;
99
111
  const id = submit.value as string;
100
112
  const p = pending.get(id);
101
- if (!p) return false;
113
+ if (!p) {
114
+ // Expired or lost to a restart: the buttons are still clickable but nothing is listening.
115
+ // Silence reads as a broken app, so always answer the click.
116
+ console.warn(`${PREFIX} submit for unknown/expired question ${id}`);
117
+ const channel = payload.channel?.id;
118
+ if (channel && userId) {
119
+ await slackPost('chat.postEphemeral', { channel, user: userId, text: '⌛ That question is no longer active (expired or I restarted). Just reply in the thread and I\'ll pick it up.' })
120
+ .catch((err) => console.error(`${PREFIX} ephemeral failed:`, (err as Error)?.message));
121
+ }
122
+ return false;
123
+ }
102
124
  pending.delete(id);
103
125
  clearTimeout(p.timer);
104
126
  const answers = parseAnswers(payload.state?.values, p.questions);
105
- if (p.messageTs) {
106
- const summary = Object.entries(answers).map(([q, a]) => `• *${q}* — ${Array.isArray(a) ? a.join(', ') : a}`).join('\n') || '_(no selection)_';
107
- await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text: '✅ Got your answers.', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: `✅ Got your answers:\n${summary}` } }] }, p.useUserToken).catch(() => {});
108
- }
127
+ const summary = Object.entries(answers).map(([q, a]) => `• *${q}* — ${Array.isArray(a) ? a.join(', ') : a}`).join('\n') || '_(no selection)_';
128
+ await retire(p, `✅ Got your answers:\n${summary}`);
109
129
  console.log(`${PREFIX} resolved question ${id} (${Object.keys(answers).length} answered)`);
110
130
  p.resolve(Object.keys(answers).length ? answers : null);
111
131
  return true;