@atolis-hq/wake 0.2.59 → 0.2.60

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.
@@ -1,6 +1,6 @@
1
1
  import { parseClaudePrintResult, parseRunnerResult } from '../../domain/schema.js';
2
2
  import { runAgentCliCommand } from '../runner/cli-command.js';
3
- import { buildStagePrompt } from '../runner/stage-prompt.js';
3
+ import { buildStagePrompt, sentinelListForApproval } from '../runner/stage-prompt.js';
4
4
  import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
5
5
  import { writeRunnerTranscript } from '../runner/transcripts.js';
6
6
  import { createAgentExecution } from '../../core/live-execution.js';
@@ -120,6 +120,80 @@ function extractTokenUsage(parsed) {
120
120
  };
121
121
  }
122
122
  const CLAUDE_CLI_NAME = 'Claude';
123
+ // Bounded — this is a single "just tell me the status" follow-up, not a
124
+ // second attempt at the task, so it never needs more than one turn.
125
+ const ENVELOPE_REPAIR_MAX_TURNS = 1;
126
+ const ENVELOPE_REPAIR_TIMEOUT_MS = 60_000;
127
+ function buildEnvelopeRepairPrompt(skipApproval) {
128
+ return [
129
+ 'Your previous reply did not end with the required `wake-result` envelope, so it could not be parsed.',
130
+ 'Reply with ONLY a fenced `wake-result` JSON block containing a `status` field, then repeat that status word on its own line after the closing fence.',
131
+ `The status must be exactly one of: ${sentinelListForApproval(skipApproval)}, reflecting the outcome of your previous turn.`,
132
+ 'Do not repeat, summarize, or redo any of your previous work — this reply is parsed automatically and anything besides the envelope is discarded.',
133
+ ].join('\n');
134
+ }
135
+ function mergeTokenUsage(base, extra) {
136
+ if (extra === undefined) {
137
+ return base;
138
+ }
139
+ if (base === undefined) {
140
+ return extra;
141
+ }
142
+ return {
143
+ inputTokens: base.inputTokens + extra.inputTokens,
144
+ outputTokens: base.outputTokens + extra.outputTokens,
145
+ ...(base.cacheCreationInputTokens === undefined && extra.cacheCreationInputTokens === undefined
146
+ ? {}
147
+ : {
148
+ cacheCreationInputTokens: (base.cacheCreationInputTokens ?? 0) + (extra.cacheCreationInputTokens ?? 0),
149
+ }),
150
+ ...(base.cacheReadInputTokens === undefined && extra.cacheReadInputTokens === undefined
151
+ ? {}
152
+ : {
153
+ cacheReadInputTokens: (base.cacheReadInputTokens ?? 0) + (extra.cacheReadInputTokens ?? 0),
154
+ }),
155
+ ...(base.costUsd === undefined && extra.costUsd === undefined
156
+ ? {}
157
+ : { costUsd: (base.costUsd ?? 0) + (extra.costUsd ?? 0) }),
158
+ ...(base.turns === undefined && extra.turns === undefined
159
+ ? {}
160
+ : { turns: (base.turns ?? 0) + (extra.turns ?? 0) }),
161
+ };
162
+ }
163
+ // Asks the same (still-live) session to restate just its result envelope,
164
+ // for the case where a run otherwise completed but the model forgot the
165
+ // mandatory trailer — cheaper and more honest than defaulting the whole run
166
+ // to BLOCKED because of a formatting slip. Returns undefined on any failure
167
+ // so the caller falls back to the original (unparseable) result untouched.
168
+ async function attemptEnvelopeRepair(input) {
169
+ const args = buildClaudePrintArgs({
170
+ model: input.model,
171
+ prompt: buildEnvelopeRepairPrompt(input.skipApproval),
172
+ sessionName: input.sessionName,
173
+ resumeSessionId: input.sessionId,
174
+ maxTurns: ENVELOPE_REPAIR_MAX_TURNS,
175
+ });
176
+ const result = await runClaudeCommand({
177
+ command: input.command,
178
+ args,
179
+ cwd: input.cwd,
180
+ timeoutMs: input.timeoutMs,
181
+ });
182
+ if (result.exitCode !== 0 || result.timedOut || result.stdout.trim().length === 0) {
183
+ return undefined;
184
+ }
185
+ try {
186
+ const parsed = parseClaudePrintOutput(result.stdout);
187
+ const repairTokenUsage = extractTokenUsage(parsed);
188
+ return {
189
+ text: parsed.result,
190
+ ...(repairTokenUsage === undefined ? {} : { tokenUsage: repairTokenUsage }),
191
+ };
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
123
197
  export function classifyClaudeCliFailure(input) {
124
198
  if (input.timedOut) {
125
199
  return 'infra';
@@ -341,6 +415,30 @@ export function createClaudeRunner(options) {
341
415
  ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
342
416
  }));
343
417
  const tokenUsage = extractTokenUsage(parsed);
418
+ let effectiveResultText = parsed.result;
419
+ let effectiveTokenUsage = tokenUsage;
420
+ let envelopeRepaired = false;
421
+ if (parseRunnerResult(effectiveResultText).envelope === 'missing' &&
422
+ parsed.session_id !== undefined) {
423
+ const repair = await attemptEnvelopeRepair({
424
+ command: options.command,
425
+ cwd: input.workspacePath ?? options.cwd,
426
+ model,
427
+ sessionName,
428
+ sessionId: parsed.session_id,
429
+ skipApproval: stagePrompt.skipApproval,
430
+ timeoutMs: Math.min(options.settings.timeoutMs, ENVELOPE_REPAIR_TIMEOUT_MS),
431
+ });
432
+ if (repair !== undefined && parseRunnerResult(repair.text).envelope !== 'missing') {
433
+ effectiveResultText = `${effectiveResultText.trimEnd()}\n\n${repair.text.trim()}`;
434
+ effectiveTokenUsage = mergeTokenUsage(effectiveTokenUsage, repair.tokenUsage);
435
+ envelopeRepaired = true;
436
+ console.log(`[claude-run] envelope repair succeeded runId=${input.runId} sessionId=${parsed.session_id}`);
437
+ }
438
+ else {
439
+ console.error(`[claude-run] envelope repair failed runId=${input.runId} sessionId=${parsed.session_id}`);
440
+ }
441
+ }
344
442
  if (tokenUsage !== undefined) {
345
443
  await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
346
444
  type: 'agent.usage.updated',
@@ -364,20 +462,21 @@ export function createClaudeRunner(options) {
364
462
  payload: { exitCode: result.exitCode, timedOut: result.timedOut },
365
463
  }));
366
464
  return {
367
- result: parsed.result,
465
+ result: effectiveResultText,
368
466
  model,
369
467
  cli: CLAUDE_CLI_NAME,
370
- ...(parseRunnerResult(parsed.result).status === 'FAILED'
468
+ ...(parseRunnerResult(effectiveResultText).status === 'FAILED'
371
469
  ? { failureClass: 'task' }
372
470
  : {}),
373
471
  ...(parsed.session_id === undefined ? {} : { session_id: parsed.session_id }),
374
- ...(tokenUsage === undefined ? {} : { tokenUsage }),
472
+ ...(effectiveTokenUsage === undefined ? {} : { tokenUsage: effectiveTokenUsage }),
375
473
  metadata: {
376
474
  stdout: result.stdout,
377
475
  stderr: result.stderr,
378
476
  raw: parsed,
379
477
  skipApproval: stagePrompt.skipApproval,
380
478
  allowAutoApproval: stagePrompt.allowAutoApproval,
479
+ ...(envelopeRepaired ? { envelopeRepaired: true } : {}),
381
480
  ...(promptTranscriptPath === undefined ? {} : { promptTranscriptPath }),
382
481
  ...(responseTranscriptPath === undefined ? {} : { responseTranscriptPath }),
383
482
  ...(sandboxLog?.metadata ?? {}),
@@ -82,7 +82,7 @@ function parseFrontmatterMaxTurns(input) {
82
82
  }
83
83
  return parsed;
84
84
  }
85
- function sentinelListForApproval(skipApproval) {
85
+ export function sentinelListForApproval(skipApproval) {
86
86
  return skipApproval ? 'DONE, BLOCKED, FAILED' : 'AWAITING_APPROVAL, BLOCKED, FAILED';
87
87
  }
88
88
  function sentinelInstructionsForApproval(skipApproval) {
@@ -121,7 +121,7 @@ function buildHarnessPrompt(input) {
121
121
  if (input.upstreamChanges !== undefined && input.upstreamChanges.trim().length > 0) {
122
122
  lines.push('', 'Upstream update notice:', 'Before resuming this session, Wake pulled the latest default-branch changes into your workspace. New commits included:', input.upstreamChanges.trimEnd());
123
123
  }
124
- lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.skipApproval)}.`, sentinelInstructionsForApproval(input.skipApproval), 'The JSON object must contain only the `status` field. Do not add other fields.');
124
+ lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.skipApproval)}.`, sentinelInstructionsForApproval(input.skipApproval), 'The JSON object must contain only the `status` field. Do not add other fields.', 'This envelope is mandatory, not optional formatting: an automated parser reads only your final lines, and a reply that omits it is discarded and treated as BLOCKED regardless of the work you actually completed.');
125
125
  if (input.prTrackingEnabled) {
126
126
  lines.push('', 'Artifact reporting:', 'If you created a pull request during this stage, report it before the result envelope by adding a fenced `wake-artifacts` JSON block:', '```wake-artifacts', '{ "artifacts": [{ "kind": "pr", "url": "<the PR URL>" }] }', '```', 'Only report a PR you actually created in this run. Omit the block entirely if you created no PR.');
127
127
  }
@@ -232,8 +232,9 @@ export async function buildStagePrompt(input) {
232
232
  commentSections,
233
233
  includeRepoDetails: resolvedWorkspaceMode === 'read-only',
234
234
  });
235
+ const envelopeReminder = 'Before you finish: end this reply with the `wake-result` envelope exactly as your instructions describe, or this run is discarded and marked BLOCKED.';
235
236
  return {
236
- prompt: `${renderedTemplate}\n\n${untrustedDataBlock}`,
237
+ prompt: `${renderedTemplate}\n\n${untrustedDataBlock}\n\n${envelopeReminder}`,
237
238
  harnessPrompt: buildHarnessPrompt({
238
239
  skipApproval,
239
240
  prTrackingEnabled: input.config?.sources.github.enabled === true &&
@@ -108,7 +108,7 @@ function classifyFailedRun(input) {
108
108
  ? 'unknown'
109
109
  : 'none';
110
110
  const failurePhase = input.failurePhase ??
111
- (input.envelope === 'degraded' && input.sentinel === 'FAILED'
111
+ ((input.envelope === 'degraded' || input.envelope === 'missing') && input.sentinel === 'FAILED'
112
112
  ? 'result-parsing'
113
113
  : failurePhaseForRecord(input.record));
114
114
  let retrySafety;
@@ -1117,7 +1117,11 @@ export function parseRunnerResult(result) {
1117
1117
  return {
1118
1118
  status: body.length === 0 ? 'FAILED' : 'BLOCKED',
1119
1119
  body,
1120
- envelope: 'degraded',
1120
+ // No structured envelope AND no recognizable bare sentinel at all —
1121
+ // distinct from 'degraded' (a deliberate bare-sentinel reply) so
1122
+ // callers can retry a runner that simply forgot the trailer instead
1123
+ // of trusting a fabricated BLOCKED/FAILED default.
1124
+ envelope: 'missing',
1121
1125
  };
1122
1126
  }
1123
1127
  let removed = false;
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g2fd2fbe";
127
+ export const wakeVersion = "gf59c4fc";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.59",
3
+ "version": "0.2.60",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {