@kingcrab/pi-imessage 1.1.0 → 1.1.1

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.
@@ -0,0 +1,37 @@
1
+ const REASON_LIMIT = 200;
2
+ const failedAssistant = (message) => message.role === "assistant" && message.stopReason === "error";
3
+ /**
4
+ * Service-log error lines for failures that break normal operation (ADR 0053). Durable keeps a failed
5
+ * task only in its records, so without this an exhausted provider quota leaves stdout silent while the
6
+ * process, port and HTTP checks look healthy. Each error kind is one branch here; the first is a model
7
+ * call that ends failed, which leaves the chat or scheduled job without the answer it was waiting for.
8
+ */
9
+ export function logErrors(harness) {
10
+ return harness.subscribeCommits(({ changes }) => {
11
+ for (const change of changes) {
12
+ if (change.type !== "task" || change.value.kind !== "pi.generation")
13
+ continue;
14
+ const task = change.value;
15
+ if (task.state.status !== "terminal")
16
+ continue;
17
+ const { outcome } = task.state;
18
+ if (outcome.status === "completed" || outcome.status === "aborted")
19
+ continue;
20
+ // The failing assistant message is committed together with the task and names provider and model.
21
+ const failed = changes
22
+ .flatMap((item) => item.type === "entry" && item.value.conversationId === task.conversationId
23
+ ? (item.value.model ?? [])
24
+ : [])
25
+ .find(failedAssistant);
26
+ console.error(new Date().toISOString(), "Model call failed", {
27
+ kind: task.kind,
28
+ task: task.id,
29
+ conversation: task.conversationId,
30
+ status: outcome.status,
31
+ provider: failed?.provider,
32
+ model: failed?.model,
33
+ reason: (outcome.error?.message ?? outcome.reason ?? "").slice(0, REASON_LIMIT),
34
+ });
35
+ }
36
+ });
37
+ }
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { compactionReply, isCommand, runCommand } from "./agent/commands.js";
7
7
  import { compactChats } from "./agent/compaction.js";
8
8
  import { deliverReplies, recoverSending } from "./agent/deliver.js";
9
9
  import { deliverDirect } from "./agent/direct-send.js";
10
+ import { logErrors } from "./agent/error-log.js";
10
11
  import { openHarness } from "./agent/harness.js";
11
12
  import { submitHealth } from "./agent/health.js";
12
13
  import { openModels, readDefaults, requestSettings, withModelPolicy } from "./agent/models.js";
@@ -105,6 +106,7 @@ reloadHttpTransport) {
105
106
  const { harness, storage } = owner;
106
107
  const scheduling = schedulingExtension(harness, defaults);
107
108
  let progress;
109
+ const stopErrorLog = logErrors(harness);
108
110
  try {
109
111
  // The native owner lock is acquired before cleaning up browsers left by a previous process.
110
112
  await browser.close();
@@ -394,6 +396,7 @@ reloadHttpTransport) {
394
396
  closing ??= (async () => {
395
397
  await Promise.allSettled([...operations]);
396
398
  await progress.close();
399
+ stopErrorLog();
397
400
  await owner.close(browser.close);
398
401
  })();
399
402
  return closing;
@@ -446,10 +449,15 @@ export async function startMessaging(options, reloadHttpTransport) {
446
449
  let closing;
447
450
  let timer;
448
451
  let started = false;
452
+ /** Rows committed before the call are read even when a poll is already in flight. */
449
453
  function poll() {
450
454
  if (closed)
451
455
  return Promise.reject(new Error("Messaging is closed"));
452
- pending ??= (async () => {
456
+ const previous = pending?.catch(() => undefined) ?? Promise.resolve();
457
+ const current = previous
458
+ .then(async () => {
459
+ if (closed)
460
+ return;
453
461
  try {
454
462
  await watcher.poll();
455
463
  }
@@ -458,10 +466,13 @@ export async function startMessaging(options, reloadHttpTransport) {
458
466
  if (!closed)
459
467
  await service.deliver();
460
468
  }
461
- })().finally(() => {
462
- pending = undefined;
469
+ })
470
+ .finally(() => {
471
+ if (pending === current)
472
+ pending = undefined;
463
473
  });
464
- return pending;
474
+ pending = current;
475
+ return current;
465
476
  }
466
477
  function tick() {
467
478
  void poll()
package/dist/web/app.js CHANGED
@@ -615,7 +615,6 @@ function renderScheduled() {
615
615
 
616
616
  function renderTasks() {
617
617
  view.replaceChildren();
618
- const children = new Map();
619
618
  const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
620
619
  const chatNames = new Map(
621
620
  (data.chats?.items || []).map((chat) => [
@@ -623,70 +622,130 @@ function renderTasks() {
623
622
  chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
624
623
  ]),
625
624
  );
626
- const conversationNames = new Map(chatNames);
627
625
  const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
628
- for (const [id, name] of scheduleNames) conversationNames.set(id, name);
629
- for (const session of data.sessions?.items || []) {
630
- if (!conversationNames.has(session.conversationId))
631
- conversationNames.set(session.conversationId, session.label);
632
- }
633
- function conversationName(id) {
634
- const name = conversationNames.get(id);
635
- if (name) return name;
626
+ const sessionNames = new Map(
627
+ (data.sessions?.items || []).map((session) => [session.conversationId, session.label]),
628
+ );
629
+ // Every conversation is named by the session document that created it, or by the task that owns it.
630
+ function source(id) {
631
+ if (chatNames.has(id)) return { name: `chat ${chatNames.get(id)}`, doc: "imessage.chats" };
632
+ if (scheduleNames.has(id))
633
+ return { name: `schedule ${scheduleNames.get(id)}`, doc: "imessage.schedules" };
634
+ if (sessionNames.has(id)) return { name: `session ${sessionNames.get(id)}`, doc: "imessage.sessions" };
636
635
  const owner = conversations.get(id)?.owner;
637
- if (!owner) return "";
638
- const parentName = conversationName(owner.conversationId);
639
- return parentName ? `${parentName} / subagent` : "subagent";
636
+ return owner ? { name: "subagent", doc: "owned by a tool" } : { name: "", doc: "" };
640
637
  }
641
638
  function belongsToChat(id) {
642
639
  if (chatNames.has(id) || scheduleNames.has(id)) return true;
643
640
  const owner = conversations.get(id)?.owner;
644
641
  return owner ? belongsToChat(owner.conversationId) : false;
645
642
  }
646
- for (const conversation of data.conversations.toSorted(compareConversations)) {
647
- const key = conversation.owner ? `task-${conversation.owner.taskId}` : "root";
643
+ // Children by storage relation: conversations under their owner task, tasks under their owner task or
644
+ // conversation, entries under the task that wrote them (byTaskId). A pi.user entry opens a turn: the
645
+ // later root tasks and loose entries of its conversation nest under it until the next one.
646
+ const children = new Map();
647
+ const add = (key, node) => {
648
648
  if (!children.has(key)) children.set(key, []);
649
- children.get(key).push({ type: "conversation", ...conversation });
649
+ children.get(key).push(node);
650
+ };
651
+ const taskIds = new Set(data.tasks.map((task) => task.id));
652
+ for (const conversation of data.conversations) {
653
+ if (conversation.owner)
654
+ add(`task-${conversation.owner.taskId}`, { type: "conversation", ...conversation });
655
+ }
656
+ const turns = new Map();
657
+ for (const entry of data.entries || []) {
658
+ if (entry.kind !== "pi.user") continue;
659
+ if (!turns.has(entry.conversationId)) turns.set(entry.conversationId, []);
660
+ turns.get(entry.conversationId).push(entry.id);
661
+ }
662
+ // Entries arrive newest first; a turn lookup needs ascending ids.
663
+ for (const ids of turns.values()) ids.sort((left, right) => left - right);
664
+ function turnKey(conversationId, id) {
665
+ const opened = (turns.get(conversationId) || []).filter((turn) => turn < id).at(-1);
666
+ return opened === undefined ? `conversation-${conversationId}` : `entry-${opened}`;
650
667
  }
651
668
  for (const task of data.tasks) {
652
- const key = task.owner === undefined ? `conversation-${task.conversationId}` : `task-${task.owner}`;
653
- if (!children.has(key)) children.set(key, []);
654
- children.get(key).push({ type: "task", ...task });
669
+ add(task.owner === undefined ? turnKey(task.conversationId, task.id) : `task-${task.owner}`, {
670
+ type: "task",
671
+ ...task,
672
+ });
673
+ }
674
+ for (const entry of data.entries || []) {
675
+ if (entry.kind === "pi.user") add(`conversation-${entry.conversationId}`, { type: "entry", ...entry });
676
+ else if (entry.byTaskId !== undefined && taskIds.has(entry.byTaskId))
677
+ add(`task-${entry.byTaskId}`, { type: "entry", ...entry });
678
+ else add(turnKey(entry.conversationId, entry.id), { type: "entry", ...entry });
679
+ }
680
+ // Newest turn first in a card; inside a turn the chain reads in storage order. Cards are sorted by activity.
681
+ for (const [key, nodes] of children) {
682
+ if (key.startsWith("conversation-")) nodes.sort((left, right) => right.id - left.id);
683
+ else nodes.sort((left, right) => left.id - right.id);
684
+ }
685
+ function lazyDetails(summary, url) {
686
+ const detail = element("details");
687
+ const content = element("pre", "Loading latest state...");
688
+ detail.append(summary, content);
689
+ let loaded = false;
690
+ detail.addEventListener("toggle", async () => {
691
+ if (!detail.open || loaded) return;
692
+ loaded = true;
693
+ try {
694
+ content.textContent = JSON.stringify(await fetchData(url), null, 2);
695
+ } catch (error) {
696
+ loaded = false;
697
+ content.textContent = error.message;
698
+ }
699
+ });
700
+ return detail;
701
+ }
702
+ function taskSummary(node) {
703
+ const label =
704
+ node.kind === "pi.generation"
705
+ ? "Generation"
706
+ : node.kind === "pi.tool"
707
+ ? `Tool ${node.name || "?"}`
708
+ : node.kind;
709
+ const summary = element("summary", label);
710
+ const state = node.outcome ? [node.outcome] : [node.status, node.phase].filter(Boolean);
711
+ const waiting = node.on?.length ? ` on ${node.on.length} task${node.on.length === 1 ? "" : "s"}` : "";
712
+ const sleeping =
713
+ typeof node.until === "number" ? ` until ${new Date(node.until).toLocaleString("sv-SE")}` : "";
714
+ const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
715
+ summary.append(element("span", ` [${state.join(" / ")}]${waiting}${sleeping}${flags}`, "meta"));
716
+ return summary;
717
+ }
718
+ function entrySummary(node) {
719
+ const summary = element("summary", node.kind.replace(/^pi\./, ""));
720
+ if (node.text) summary.append(element("span", ` "${node.text}"`, "entry-text"));
721
+ return summary;
655
722
  }
656
723
  function renderNode(node) {
657
724
  const item = element("li");
658
725
  const key = `${node.type}-${node.id}`;
659
726
  item.id = `tree-${key}`;
660
727
  if (node.type === "conversation") {
661
- const heading = element("span", `Conversation #${node.id}`);
662
- const name = conversationName(node.id);
663
- if (name) heading.append(element("span", ` / ${name} `, "meta"));
664
- if (chatNames.has(node.id)) {
665
- heading.append(link("Chat", `/chat?conversationId=${node.id}`, "meta"));
666
- }
667
- item.append(heading);
728
+ const { name, doc } = source(node.id);
729
+ // A card is one conversation; a conversation owned by a task folds under it.
730
+ const heading = element(node.owner ? "summary" : "span", "Conversation");
731
+ if (name) heading.append(element("span", ` ${name}`));
732
+ if (doc) heading.append(element("span", ` <- ${doc}`, "meta"));
733
+ if (chatNames.has(node.id))
734
+ heading.append(element("span", " "), link("Chat", `/chat?conversationId=${node.id}`, "meta"));
735
+ if (node.owner) {
736
+ const detail = element("details");
737
+ detail.id = key;
738
+ detail.append(heading);
739
+ item.append(detail);
740
+ } else item.append(heading);
741
+ } else if (node.type === "task") {
742
+ const detail = lazyDetails(taskSummary(node), `/tasks/task/data?taskId=${node.id}`);
743
+ detail.id = key;
744
+ item.append(detail);
668
745
  } else {
669
- const detail = element("details");
746
+ item.className = node.kind === "pi.user" ? "entry turn" : "entry";
747
+ const detail = lazyDetails(entrySummary(node), `/tasks/entry/data?entryId=${node.id}`);
670
748
  detail.id = key;
671
- const summary = element("summary", `${node.kind}${node.name ? `: ${node.name}` : ""} #${node.id}`);
672
- const status = [node.status, node.phase, node.outcome].filter(Boolean).join(" / ");
673
- const waiting = node.on?.length ? ` on [${node.on.join(", ")}]` : "";
674
- const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
675
- summary.append(element("span", ` ${status}${waiting}${flags}`, "meta"));
676
- const content = element("pre", "Loading latest state...");
677
- detail.append(summary, content);
678
- let loaded = false;
679
- detail.addEventListener("toggle", async () => {
680
- if (!detail.open || loaded) return;
681
- loaded = true;
682
- try {
683
- const task = await fetchData(`/tasks/task/data?taskId=${node.id}`);
684
- content.textContent = JSON.stringify(task, null, 2);
685
- } catch (error) {
686
- loaded = false;
687
- content.textContent = error.message;
688
- }
689
- });
690
749
  item.append(detail);
691
750
  }
692
751
  const descendants = children.get(key) || [];
@@ -694,23 +753,29 @@ function renderTasks() {
694
753
  if (descendants.length || more) {
695
754
  const list = element("ul", undefined, "task-tree");
696
755
  for (const child of descendants) list.append(renderNode(child));
697
- if (more) list.append(element("li", "older tasks not listed", "meta"));
698
- item.append(list);
756
+ if (more) list.append(element("li", "older turns not listed", "meta"));
757
+ (node.type === "conversation" && node.owner ? item.firstChild : item).append(list);
699
758
  }
700
759
  return item;
701
760
  }
702
761
  const roots = conversationId
703
- ? data.conversations
704
- .filter((conversation) => String(conversation.id) === conversationId)
705
- .map((conversation) => ({ type: "conversation", ...conversation }))
706
- : children.get("root") || [];
762
+ ? data.conversations.filter((conversation) => String(conversation.id) === conversationId)
763
+ : data.conversations.filter((conversation) => !conversation.owner);
707
764
  const visible = roots.filter((conversation) => belongsToChat(conversation.id));
765
+ if (!conversationId && data.session)
766
+ view.append(
767
+ element(
768
+ "p",
769
+ `Session ${data.session} docs: imessage.chats, imessage.schedules, imessage.sessions`,
770
+ "muted",
771
+ ),
772
+ );
708
773
  if (!visible.length) view.append(element("p", "No conversations.", "empty"));
709
774
  for (const conversation of visible.toSorted(compareConversations)) {
710
775
  const card = element("section", undefined, "card");
711
776
  card.id = `tasks-${conversation.id}`;
712
777
  const list = element("ul", undefined, "task-tree task-root");
713
- list.append(renderNode(conversation));
778
+ list.append(renderNode({ type: "conversation", ...conversation }));
714
779
  card.append(list);
715
780
  view.append(card);
716
781
  }
@@ -281,10 +281,21 @@ export async function startWeb(options) {
281
281
  schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
282
282
  chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
283
283
  sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
284
+ session: join(options.workingDir, "durable"),
284
285
  revision: viewRevision(),
285
286
  };
286
287
  compact = true;
287
288
  }
289
+ else if (url.pathname === "/tasks/entry/data") {
290
+ const id = url.searchParams.get("entryId");
291
+ if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
292
+ throw new Error("Invalid entry ID");
293
+ value = await options.agent.harness.commit((tx) => tx.entry(Number(id)), BACKGROUND_CONTEXT);
294
+ if (!value) {
295
+ await json(response, 404, { error: "Entry not found" });
296
+ return;
297
+ }
298
+ }
288
299
  else if (url.pathname === "/tasks/task/data") {
289
300
  const id = url.searchParams.get("taskId");
290
301
  if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
@@ -439,6 +439,12 @@ body.page-logs .native-state {
439
439
  padding: 8px 0 8px 16px;
440
440
  }
441
441
 
442
+ /* Entries are history records, not work: all grey, no marker of their own. */
443
+ .task-tree li.entry > details > summary {
444
+ opacity: 0.6;
445
+ list-style: none;
446
+ }
447
+
442
448
  .schedule-row {
443
449
  background: var(--surface);
444
450
  margin-bottom: 6px;
package/dist/web/tasks.js CHANGED
@@ -1,33 +1,139 @@
1
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { UserEntry, } from "@earendil-works/pi-durable";
2
3
  import { displayHistory, historyActivity } from "./history.js";
3
- /** Newest tasks listed per conversation; older finished ones are only flagged. */
4
+ /** Newest generations shown per conversation, with every entry and task from the turn of the oldest one. */
5
+ export const RECENT_GENERATIONS_PER_CONVERSATION = 10;
6
+ /** Newest tasks shown for a conversation that never generated (schedulers). */
4
7
  export const RECENT_TASKS_PER_CONVERSATION = 20;
8
+ /** Pages of 5 entries read back from the oldest generation to find the user message that opened its turn. */
9
+ const TURN_LOOKBACK_PAGES = 4;
10
+ function firstLine(text, limit = 80) {
11
+ return text
12
+ .replace(/^\[(?:Group '[^']*'|DM|SMS) from [^\]]+\] /, "")
13
+ .split("\n")
14
+ .find((line) => line.trim())
15
+ ?.trim()
16
+ .slice(0, limit);
17
+ }
18
+ /** One line per entry: the first user or assistant message, tool calls by name only, tool results by text. */
19
+ function summarizeEntry(entry) {
20
+ const message = entry.model?.[0];
21
+ if (!message)
22
+ return { role: undefined, text: undefined };
23
+ const content = message.content;
24
+ const text = typeof content === "string"
25
+ ? content
26
+ : content
27
+ .map((block) => block.type === "text"
28
+ ? block.text
29
+ : block.type === "toolCall"
30
+ ? `[toolCall ${block.name}]`
31
+ : block.type === "thinking"
32
+ ? "[thinking]"
33
+ : `[${block.type}]`)
34
+ .filter(Boolean)
35
+ .join(" ");
36
+ return { role: message.role, text: firstLine(text) };
37
+ }
5
38
  /**
6
39
  * Read one consistent tree, newest first: all conversations, every unfinished task, every task that owns a
7
- * conversation, their parent chains and the newest tasks of each conversation. Descending scans stop at the
8
- * window instead of reading the whole task history (earendil-works/pi#10546).
40
+ * conversation and their parent chains. Per conversation the window is the newest generations plus every
41
+ * entry and task back to the `pi.user` entry that opened the oldest one's turn. Entries and tasks share one
42
+ * id sequence, so the window is a plain id range and the browser nests records by `owner` and `byTaskId`
43
+ * without any inference. Descending scans stop at the window instead of reading the whole history
44
+ * (earendil-works/pi#10546).
9
45
  */
10
46
  export function readTaskTree(harness) {
11
47
  return harness.commit(async (tx) => {
12
48
  const conversations = [];
13
49
  const kept = new Map();
50
+ const entries = [];
14
51
  const more = {};
15
52
  let cursor;
16
53
  do {
17
54
  const page = await tx.scanConversations({}, 100, cursor);
18
55
  for (const conversation of page.items) {
19
- const history = await tx.scanEntries({ conversationId: conversation.id }, 100);
20
- const { groupName } = displayHistory(history, 1);
56
+ const id = conversation.id;
57
+ // One descending scan: the newest tasks down to the oldest shown generation; a task scan with a
58
+ // conversation filter walks the global task order, so it must not run twice per conversation.
59
+ const recent = [];
60
+ let oldest;
61
+ let generations = 0;
62
+ let next;
63
+ scan: do {
64
+ const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
65
+ for (const task of tasks.items) {
66
+ if (task.kind === "pi.generation") {
67
+ if (++generations > RECENT_GENERATIONS_PER_CONVERSATION) {
68
+ more[id] = true;
69
+ break scan;
70
+ }
71
+ oldest = task;
72
+ }
73
+ else if (!generations && recent.length >= RECENT_TASKS_PER_CONVERSATION) {
74
+ more[id] = true;
75
+ break scan;
76
+ }
77
+ recent.push(task);
78
+ }
79
+ next = tasks.next;
80
+ } while (next);
81
+ let history;
82
+ let floor;
83
+ if (oldest) {
84
+ history = [];
85
+ let next;
86
+ do {
87
+ const window = await tx.scanEntries({ conversationId: id, minEntryId: oldest.id }, 100, next);
88
+ history.push(...window.items);
89
+ next = window.next;
90
+ } while (next);
91
+ // Walk back to the user message that opened the oldest generation's turn.
92
+ let max = oldest.id - 1;
93
+ for (let pages = 0; pages < TURN_LOOKBACK_PAGES; pages++) {
94
+ const older = await tx.scanEntries({ conversationId: id, maxEntryId: max }, 5);
95
+ const user = older.items.findIndex((entry) => UserEntry.is(entry));
96
+ history.push(...(user === -1 ? older.items : older.items.slice(0, user + 1)));
97
+ if (user !== -1 || !older.items.length)
98
+ break;
99
+ max = older.items[older.items.length - 1].id - 1;
100
+ }
101
+ const turn = history.find((entry) => UserEntry.is(entry) && entry.id < oldest.id);
102
+ floor = turn ? turn.id : oldest.id;
103
+ history = history.filter((entry) => entry.id >= floor);
104
+ // Tasks between the turn's user message and its first generation sit on the next page.
105
+ while (next && (recent.at(-1)?.id ?? 0) >= floor) {
106
+ const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
107
+ recent.push(...tasks.items.filter((task) => task.id >= floor));
108
+ next = tasks.next;
109
+ }
110
+ }
111
+ else {
112
+ // Names and activity still come from the newest 100 entries, as on the Messages page.
113
+ history = [...(await tx.scanEntries({ conversationId: id }, 100)).items];
114
+ floor = history[RECENT_TASKS_PER_CONVERSATION - 1]?.id ?? 0;
115
+ }
116
+ const page = { items: history, next: undefined };
117
+ const { groupName } = displayHistory(page, 1);
21
118
  conversations.push({
22
119
  ...conversation,
23
- updatedAt: historyActivity(history),
120
+ updatedAt: historyActivity(page),
24
121
  groupName: typeof groupName === "string" ? groupName : undefined,
25
122
  });
26
- const newest = await tx.scanTasks({ conversationId: conversation.id, order: "descending" }, RECENT_TASKS_PER_CONVERSATION + 1);
27
- for (const task of newest.items.slice(0, RECENT_TASKS_PER_CONVERSATION))
28
- kept.set(task.id, task);
29
- if (newest.items.length > RECENT_TASKS_PER_CONVERSATION)
30
- more[conversation.id] = true;
123
+ for (const entry of history) {
124
+ if (entry.id < floor)
125
+ continue;
126
+ entries.push({
127
+ id: entry.id,
128
+ conversationId: id,
129
+ kind: entry.kind,
130
+ byTaskId: entry.byTaskId,
131
+ ...summarizeEntry(entry),
132
+ });
133
+ }
134
+ for (const task of recent)
135
+ if (!oldest || task.id >= floor)
136
+ kept.set(task.id, task);
31
137
  if (conversation.owner && !kept.has(conversation.owner.taskId)) {
32
138
  const owner = await tx.task(conversation.owner.taskId);
33
139
  if (owner)
@@ -85,13 +191,15 @@ export function readTaskTree(harness) {
85
191
  background: task.background,
86
192
  abortRequested: task.abortRequested,
87
193
  status: state.status,
194
+ // Phases exist only while a task runs; a terminal record keeps its outcome and drops the checkpoint.
88
195
  phase: state.checkpoint && typeof state.checkpoint === "object" && !Array.isArray(state.checkpoint)
89
196
  ? state.checkpoint.phase
90
197
  : undefined,
91
198
  on: state.status === "waiting" ? state.on : undefined,
199
+ until: state.status === "sleeping" ? state.until : undefined,
92
200
  outcome: state.outcome?.status,
93
201
  });
94
202
  }
95
- return { conversations, tasks, more };
203
+ return { conversations, tasks, entries, more };
96
204
  }, BACKGROUND_CONTEXT);
97
205
  }
@@ -0,0 +1,44 @@
1
+ # 0053. Service-log errors for failures that break normal operation
2
+
3
+ Status: proposed
4
+
5
+ **Problem.** Durable records every task outcome, but only in its storage.
6
+ `service.log` shows startup, sends and a few delivery warnings. On 2026-10-25
7
+ the nightly memory reflection failed three times because the model child had
8
+ exhausted a provider quota; the process, the port and the root HTTP check
9
+ looked healthy and `service.log` had no line about it. The failure was only
10
+ visible by reading `durable/main.jsonl` or by a chat notice that was deduped.
11
+
12
+ **Example.**
13
+
14
+ ```text
15
+ 2026-10-25T00:14:48.924Z Model call failed {
16
+ kind: 'pi.generation', task: 183434, conversation: 183426, status: 'failed',
17
+ provider: 'openai-codex', model: 'gpt-6.1-sol',
18
+ reason: 'You have hit your ChatGPT usage limit (plus plan). Try again in ~291 min.'
19
+ }
20
+ ```
21
+
22
+ **Decision.**
23
+
24
+ - `service.log` gets a `console.error` line with an ISO timestamp for a failure
25
+ that breaks normal operation. A failure qualifies when either holds: a chat
26
+ or scheduled job does not get the result it was waiting for, or the system
27
+ enters a state that needs an operator. Transient errors that recover on
28
+ their own, such as a retried provider error or a delivery whose status is
29
+ later confirmed, do not qualify.
30
+ - One module, `src/agent/error-log.ts`, owns these lines. It observes Durable
31
+ commits synchronously and never calls Session APIs. Each error kind is one
32
+ branch with its own message name; adding a kind states which of the two
33
+ conditions it meets.
34
+ - The first kind is a model call that ends `failed` or `faulted`: the line
35
+ carries the task, conversation, provider, model and the reason truncated to
36
+ 200 characters. It carries no prompt, tool argument or input content.
37
+ - Other task kinds, including scheduled jobs that fault, are not logged yet;
38
+ they are added under the same conditions when needed.
39
+
40
+ **Consequences.** A quota, auth or provider outage is visible in the service
41
+ log within the same commit that records it, so a health check that reads the
42
+ log catches it without a paid model probe. The web `/logs` page shows the same
43
+ lines. Durable records remain the full source of truth; the log is a pointer
44
+ to them, not a replacement.
@@ -34,6 +34,7 @@ Accepted ADR files are never edited; this index records which ones are current.
34
34
  | [0048](0048-quiet-attachment-reads.md) | Quiet attachment reads | Proposed; owner-requested implementation in #38 |
35
35
  | [0049](0049-per-chat-progress-notices.md) | Per-chat progress notices via `/verbose` | Proposed; owner-requested implementation |
36
36
  | [0052](0052-host-owned-self-restart.md) | The host owns its own restart (`restart_service`) | Active; owner accepted on 2026-10-24 (#39) |
37
+ | [0053](0053-service-log-errors.md) | Service-log errors for failures that break normal operation | Proposed; first kind (model call failed) implemented |
37
38
 
38
39
  ## Replaced or rejected
39
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kingcrab/pi-imessage",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "A minimal, self-managing iMessage bot powered by Pi Durable",
5
5
  "license": "MIT",
6
6
  "repository": {