@cyanautomation/kaseki-agent 1.14.0 → 1.15.0

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,68 @@
1
+ /**
2
+ * Test Utilities: Log Suppression Helpers
3
+ *
4
+ * Provides per-test control over log suppression when you need to debug
5
+ * specific tests or capture logs for assertions.
6
+ *
7
+ * Example usage:
8
+ * import { suppressLogs, restoreLogs, getCapturedLogs } from '@src/test-utils/log-suppression';
9
+ *
10
+ * it('should test something with logging', () => {
11
+ * suppressLogs();
12
+ * // ... test code that would normally print logs
13
+ * restoreLogs();
14
+ * const logs = getCapturedLogs();
15
+ * // ... assertions on captured logs
16
+ * });
17
+ */
18
+ declare global {
19
+ namespace NodeJS {
20
+ interface Global {
21
+ __kasekiCapturedLogs?: string[];
22
+ __kasekiOriginalLog?: typeof console.log;
23
+ __kasekiLogsEnabled?: boolean;
24
+ }
25
+ }
26
+ }
27
+ /**
28
+ * Suppress JSON event logs for the current test.
29
+ * Logs are still captured in __kasekiCapturedLogs and can be retrieved.
30
+ */
31
+ export declare function suppressLogs(): void;
32
+ /**
33
+ * Restore console.log to its original state, allowing all logs to print.
34
+ */
35
+ export declare function restoreLogs(): void;
36
+ /**
37
+ * Get all JSON event logs that were suppressed/captured since the last clearCapturedLogs() call.
38
+ * Useful for asserting on log output without printing it during test execution.
39
+ *
40
+ * @returns Array of JSON event log strings
41
+ */
42
+ export declare function getCapturedLogs(): string[];
43
+ /**
44
+ * Clear the captured logs buffer. Call this between tests if you need a fresh slate.
45
+ */
46
+ export declare function clearCapturedLogs(): void;
47
+ /**
48
+ * Get a specific captured log by event type.
49
+ *
50
+ * @param eventType - The event_type field to search for (e.g., 'job_started')
51
+ * @returns The first matching log object or undefined
52
+ */
53
+ export declare function getCapturedLogByEventType(eventType: string): Record<string, any> | undefined;
54
+ /**
55
+ * Get all captured logs of a specific event type.
56
+ *
57
+ * @param eventType - The event_type field to search for
58
+ * @returns Array of matching log objects
59
+ */
60
+ export declare function getCapturedLogsByEventType(eventType: string): Record<string, any>[];
61
+ /**
62
+ * Run a test function with logs suppressed, automatically cleaning up.
63
+ * Convenient wrapper for tests that just need suppression without explicit restore.
64
+ *
65
+ * @param testFn - The test function to run with suppressed logs
66
+ */
67
+ export declare function withSuppressedLogs(testFn: () => void | Promise<void>): void | Promise<void>;
68
+ //# sourceMappingURL=log-suppression.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-suppression.d.ts","sourceRoot":"","sources":["../../src/test-utils/log-suppression.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM,CAAC;QACf,UAAU,MAAM;YACd,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;YAChC,mBAAmB,CAAC,EAAE,OAAO,OAAO,CAAC,GAAG,CAAC;YACzC,mBAAmB,CAAC,EAAE,OAAO,CAAC;SAC/B;KACF;CACF;AAGD;;;GAGG;AACH,wBAAgB,YAAY,IAAI,IAAI,CA0BnC;AAED;;GAEG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAOlC;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,EAAE,CAE1C;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAa5F;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAgBnF;AAqBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAa3F"}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Test Utilities: Log Suppression Helpers
3
+ *
4
+ * Provides per-test control over log suppression when you need to debug
5
+ * specific tests or capture logs for assertions.
6
+ *
7
+ * Example usage:
8
+ * import { suppressLogs, restoreLogs, getCapturedLogs } from '@src/test-utils/log-suppression';
9
+ *
10
+ * it('should test something with logging', () => {
11
+ * suppressLogs();
12
+ * // ... test code that would normally print logs
13
+ * restoreLogs();
14
+ * const logs = getCapturedLogs();
15
+ * // ... assertions on captured logs
16
+ * });
17
+ */
18
+ /* eslint-enable @typescript-eslint/no-namespace */
19
+ /**
20
+ * Suppress JSON event logs for the current test.
21
+ * Logs are still captured in __kasekiCapturedLogs and can be retrieved.
22
+ */
23
+ export function suppressLogs() {
24
+ if (global.__kasekiLogsEnabled === false) {
25
+ return; // Already suppressed
26
+ }
27
+ // Store the current console.log
28
+ global.__kasekiOriginalLog = console.log;
29
+ // Replace with suppressing version
30
+ console.log = (...args) => {
31
+ const filteredArgs = args
32
+ .map((arg) => {
33
+ if (typeof arg === 'string' && isJsonEventLog(arg)) {
34
+ global.__kasekiCapturedLogs?.push(arg);
35
+ return null;
36
+ }
37
+ return arg;
38
+ })
39
+ .filter((arg) => arg !== null);
40
+ if (filteredArgs.length > 0) {
41
+ global.__kasekiOriginalLog?.(...filteredArgs);
42
+ }
43
+ };
44
+ global.__kasekiLogsEnabled = false;
45
+ }
46
+ /**
47
+ * Restore console.log to its original state, allowing all logs to print.
48
+ */
49
+ export function restoreLogs() {
50
+ if (global.__kasekiLogsEnabled === true || global.__kasekiOriginalLog === undefined) {
51
+ return; // Already restored or nothing to restore
52
+ }
53
+ console.log = global.__kasekiOriginalLog;
54
+ global.__kasekiLogsEnabled = true;
55
+ }
56
+ /**
57
+ * Get all JSON event logs that were suppressed/captured since the last clearCapturedLogs() call.
58
+ * Useful for asserting on log output without printing it during test execution.
59
+ *
60
+ * @returns Array of JSON event log strings
61
+ */
62
+ export function getCapturedLogs() {
63
+ return global.__kasekiCapturedLogs ?? [];
64
+ }
65
+ /**
66
+ * Clear the captured logs buffer. Call this between tests if you need a fresh slate.
67
+ */
68
+ export function clearCapturedLogs() {
69
+ global.__kasekiCapturedLogs = [];
70
+ }
71
+ /**
72
+ * Get a specific captured log by event type.
73
+ *
74
+ * @param eventType - The event_type field to search for (e.g., 'job_started')
75
+ * @returns The first matching log object or undefined
76
+ */
77
+ export function getCapturedLogByEventType(eventType) {
78
+ const logs = getCapturedLogs();
79
+ for (const logStr of logs) {
80
+ try {
81
+ const log = JSON.parse(logStr);
82
+ if (log.event_type === eventType) {
83
+ return log;
84
+ }
85
+ }
86
+ catch {
87
+ // Skip invalid JSON
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ /**
93
+ * Get all captured logs of a specific event type.
94
+ *
95
+ * @param eventType - The event_type field to search for
96
+ * @returns Array of matching log objects
97
+ */
98
+ export function getCapturedLogsByEventType(eventType) {
99
+ const logs = getCapturedLogs();
100
+ const matches = [];
101
+ for (const logStr of logs) {
102
+ try {
103
+ const log = JSON.parse(logStr);
104
+ if (log.event_type === eventType) {
105
+ matches.push(log);
106
+ }
107
+ }
108
+ catch {
109
+ // Skip invalid JSON
110
+ }
111
+ }
112
+ return matches;
113
+ }
114
+ /**
115
+ * Check if a JSON event log is valid (has required fields).
116
+ * This is the same check used in jest.setup.ts.
117
+ */
118
+ function isJsonEventLog(str) {
119
+ try {
120
+ const obj = JSON.parse(str);
121
+ const isEventLog = typeof obj === 'object' &&
122
+ obj !== null &&
123
+ 'timestamp' in obj &&
124
+ 'component' in obj &&
125
+ ('event_type' in obj || 'level' in obj || 'message' in obj);
126
+ return isEventLog;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Run a test function with logs suppressed, automatically cleaning up.
134
+ * Convenient wrapper for tests that just need suppression without explicit restore.
135
+ *
136
+ * @param testFn - The test function to run with suppressed logs
137
+ */
138
+ export function withSuppressedLogs(testFn) {
139
+ suppressLogs();
140
+ try {
141
+ const result = testFn();
142
+ if (result instanceof Promise) {
143
+ return result.finally(() => restoreLogs());
144
+ }
145
+ restoreLogs();
146
+ return result;
147
+ }
148
+ catch (error) {
149
+ restoreLogs();
150
+ throw error;
151
+ }
152
+ }
153
+ //# sourceMappingURL=log-suppression.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-suppression.js","sourceRoot":"","sources":["../../src/test-utils/log-suppression.ts"],"names":[],"mappings":"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"}
package/kaseki-agent.sh CHANGED
@@ -30,6 +30,7 @@ KASEKI_VALIDATION_FAIL_FAST="${KASEKI_VALIDATION_FAIL_FAST:-1}"
30
30
  KASEKI_STRICT_SCRIPT_CHECK="${KASEKI_STRICT_SCRIPT_CHECK:-0}"
31
31
  GITHUB_APP_ENABLED="${GITHUB_APP_ENABLED:-0}"
32
32
  KASEKI_PUBLISH_MODE="${KASEKI_PUBLISH_MODE:-auto}"
33
+ KASEKI_GITHUB_PR_RETRIES="${KASEKI_GITHUB_PR_RETRIES:-3}"
33
34
  START_EPOCH="$(date +%s)"
34
35
  START_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
35
36
  CURRENT_STAGE="initializing"
@@ -50,6 +51,9 @@ QUALITY_FAILURE_REASON=""
50
51
  SECRET_SCAN_EXIT=0
51
52
  GITHUB_PUSH_EXIT=0
52
53
  GITHUB_PR_EXIT=0
54
+ GITHUB_API_ERROR_TYPE=""
55
+ GITHUB_API_ERROR_MESSAGE=""
56
+ GITHUB_API_HTTP_STATUS=""
53
57
  ACTUAL_MODEL="unknown"
54
58
  GITHUB_PR_URL=""
55
59
  GITHUB_SKIP_REASONS=()
@@ -308,6 +312,9 @@ write_metadata() {
308
312
  "repo_memory_file": $(printf '%s' "$REPO_MEMORY_FILE" | json_encode),
309
313
  "repo_memory_ttl_days": $KASEKI_REPO_MEMORY_TTL_DAYS,
310
314
  "repo_memory_max_bytes": $KASEKI_REPO_MEMORY_MAX_BYTES,
315
+ "github_api_error_type": $(printf '%s' "$GITHUB_API_ERROR_TYPE" | json_encode),
316
+ "github_api_error_message": $(printf '%s' "$GITHUB_API_ERROR_MESSAGE" | json_encode),
317
+ "github_api_http_status": $(printf '%s' "$GITHUB_API_HTTP_STATUS" | json_encode),
311
318
  "node_version": $(node --version 2>/dev/null | json_encode || printf 'null'),
312
319
  "npm_version": $(npm --version 2>/dev/null | json_encode || printf 'null'),
313
320
  "pi_version": $(printf '%s' "$PI_VERSION" | json_encode)
@@ -1259,6 +1266,102 @@ $memory_section
1259
1266
  EOF
1260
1267
  }
1261
1268
 
1269
+ validate_github_api_response() {
1270
+ local http_status response log_file error_type error_message
1271
+ http_status="$1"
1272
+ response="$2"
1273
+ log_file="${3:-/results/git-push.log}"
1274
+
1275
+ # Try to parse error info from response
1276
+ error_type="unknown"
1277
+ error_message=""
1278
+
1279
+ if [ "$http_status" = "201" ]; then
1280
+ # Success - but still need to verify html_url exists
1281
+ return 0
1282
+ fi
1283
+
1284
+ # Attempt to extract error info using Node.js
1285
+ {
1286
+ error_message=$(printf '%s' "$response" | node -e "
1287
+ try {
1288
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
1289
+ if (d.message) process.stdout.write(d.message);
1290
+ } catch (e) {}
1291
+ " 2>/dev/null || true)
1292
+ }
1293
+
1294
+ # Map HTTP status to error type
1295
+ case "$http_status" in
1296
+ 400)
1297
+ error_type="validation_error"
1298
+ [ -z "$error_message" ] && error_message="Bad request"
1299
+ ;;
1300
+ 401)
1301
+ error_type="authentication_error"
1302
+ [ -z "$error_message" ] && error_message="Unauthorized (check GitHub App token)"
1303
+ ;;
1304
+ 403)
1305
+ error_type="permission_error"
1306
+ [ -z "$error_message" ] && error_message="Permission denied (insufficient scope or rate limited)"
1307
+ ;;
1308
+ 404)
1309
+ error_type="not_found_error"
1310
+ [ -z "$error_message" ] && error_message="Repository or branch not found"
1311
+ ;;
1312
+ 422)
1313
+ error_type="validation_error"
1314
+ [ -z "$error_message" ] && error_message="Unprocessable entity (e.g., branch protection, duplicate PR)"
1315
+ ;;
1316
+ 429)
1317
+ error_type="rate_limit_error"
1318
+ [ -z "$error_message" ] && error_message="Rate limited by GitHub API"
1319
+ ;;
1320
+ 500|502|503|504)
1321
+ error_type="server_error"
1322
+ [ -z "$error_message" ] && error_message="GitHub API server error (HTTP $http_status)"
1323
+ ;;
1324
+ *)
1325
+ error_type="http_error"
1326
+ [ -z "$error_message" ] && error_message="HTTP $http_status"
1327
+ ;;
1328
+ esac
1329
+
1330
+ printf 'GitHub API error (HTTP %s): %s - %s\n' "$http_status" "$error_type" "$error_message" | tee -a "$log_file" >&2
1331
+
1332
+ # Store error info for logging
1333
+ GITHUB_API_ERROR_TYPE="$error_type"
1334
+ GITHUB_API_ERROR_MESSAGE="$error_message"
1335
+ GITHUB_API_HTTP_STATUS="$http_status"
1336
+
1337
+ return 1
1338
+ }
1339
+
1340
+ is_github_pr_error_retryable() {
1341
+ local http_status error_type
1342
+ http_status="$1"
1343
+ error_type="$2"
1344
+
1345
+ # Retryable: transient errors
1346
+ case "$http_status" in
1347
+ 429)
1348
+ # Rate limit (retryable)
1349
+ return 0
1350
+ ;;
1351
+ 500|502|503|504)
1352
+ # Server errors (retryable)
1353
+ return 0
1354
+ ;;
1355
+ 0)
1356
+ # curl failed (usually transient)
1357
+ return 0
1358
+ ;;
1359
+ esac
1360
+
1361
+ # Non-retryable: permanent errors
1362
+ return 1
1363
+ }
1364
+
1262
1365
  run_github_operations() {
1263
1366
  local app_id private_key_file owner repo feature_branch token token_data
1264
1367
 
@@ -1366,7 +1469,8 @@ EOF_ASKPASS
1366
1469
 
1367
1470
  # Create pull request
1368
1471
  printf 'Creating pull request...\n' | tee -a /results/git-push.log
1369
- local pr_title pr_body pr_response pr_url
1472
+ emit_progress "github operations" "pr_creation_starting"
1473
+ local pr_title pr_body pr_response pr_url pr_http_status
1370
1474
  pr_title="Kaseki: $INSTANCE_NAME"
1371
1475
  pr_body=$(cat <<EOF
1372
1476
  Generated by Kaseki agent (instance: $INSTANCE_NAME)
@@ -1383,21 +1487,118 @@ This PR is in draft status. Please review before merging.
1383
1487
  EOF
1384
1488
  )
1385
1489
 
1386
- pr_response=$(curl -s -X POST \
1387
- -H "Authorization: token $token" \
1388
- -H "Accept: application/vnd.github.v3+json" \
1389
- "https://api.github.com/repos/$owner/$repo/pulls" \
1390
- -d "{\"title\": $(printf '%s' "$pr_title" | node -e "console.log(JSON.stringify(require('fs').readFileSync(0, 'utf8')))"), \"body\": $(printf '%s' "$pr_body" | node -e "console.log(JSON.stringify(require('fs').readFileSync(0, 'utf8')))"), \"head\": \"$feature_branch\", \"base\": \"$GIT_REF\", \"draft\": true}" 2>&1)
1490
+ # Retry loop for transient errors
1491
+ local retry_count=0 max_retries="$KASEKI_GITHUB_PR_RETRIES" pr_created=0
1492
+ local backoff_delay=2
1391
1493
 
1392
- pr_url="$(printf '%s' "$pr_response" | node -e "const d = JSON.parse(require('fs').readFileSync(0, 'utf8')); process.stdout.write(d.html_url || '')" 2>/dev/null || true)"
1494
+ while [ $retry_count -le "$max_retries" ]; do
1495
+ if [ $retry_count -gt 0 ]; then
1496
+ printf 'Retrying PR creation (attempt %d of %d) after %ds delay...\n' $((retry_count + 1)) "$max_retries" "$backoff_delay" | tee -a /results/git-push.log
1497
+ emit_progress "github operations" "pr_creation_attempt $((retry_count + 1))/$max_retries"
1498
+ sleep "$backoff_delay"
1499
+ # Exponential backoff: 2s, 4s, 8s
1500
+ backoff_delay=$((backoff_delay * 2))
1501
+ if [ $backoff_delay -gt 8 ]; then backoff_delay=8; fi
1502
+ fi
1503
+
1504
+ # Capture both response and HTTP status code
1505
+ local pr_response_file temp_status_file
1506
+ pr_response_file="$(mktemp /tmp/kaseki-pr-response.XXXXXX)" || { printf 'Failed to create temp file for PR response\n' | tee -a /results/git-push.log >&2; GITHUB_PR_EXIT=8; return 8; }
1507
+ temp_status_file="$(mktemp /tmp/kaseki-pr-status.XXXXXX)" || { printf 'Failed to create temp file for PR status\n' | tee -a /results/git-push.log >&2; GITHUB_PR_EXIT=8; return 8; }
1508
+
1509
+ if [ $retry_count -eq 0 ] && [ "${KASEKI_DEBUG:-0}" = "1" ]; then
1510
+ printf 'Debug: Creating PR with head=%s, base=%s, draft=true\n' "$feature_branch" "$GIT_REF" | tee -a /results/git-push.log
1511
+ fi
1512
+
1513
+ # Use curl with -w to capture HTTP status separately
1514
+ # curl exit code: 0=success, non-0=failure
1515
+ local curl_exit
1516
+ curl -s -w '%{http_code}' -X POST \
1517
+ -H "Authorization: token $token" \
1518
+ -H "Accept: application/vnd.github.v3+json" \
1519
+ "https://api.github.com/repos/$owner/$repo/pulls" \
1520
+ -d "{\"title\": $(printf '%s' "$pr_title" | node -e "console.log(JSON.stringify(require('fs').readFileSync(0, 'utf8')))"), \"body\": $(printf '%s' "$pr_body" | node -e "console.log(JSON.stringify(require('fs').readFileSync(0, 'utf8')))"), \"head\": \"$feature_branch\", \"base\": \"$GIT_REF\", \"draft\": true}" > "$temp_status_file" 2>&1
1521
+ curl_exit=$?
1522
+
1523
+ # Split response and status code
1524
+ local response_with_status
1525
+ response_with_status="$(cat "$temp_status_file")"
1526
+ pr_http_status="${response_with_status: -3}"
1527
+ pr_response="${response_with_status%???}"
1528
+
1529
+ rm -f "$temp_status_file"
1530
+
1531
+ if [ $curl_exit -ne 0 ]; then
1532
+ # curl command itself failed (network error, timeout, etc.)
1533
+ printf 'GitHub PR API curl command failed with exit code %d (attempt %d)\n' "$curl_exit" $((retry_count + 1)) | tee -a /results/git-push.log >&2
1534
+ GITHUB_API_HTTP_STATUS="0"
1535
+ if is_github_pr_error_retryable "0" "curl_error"; then
1536
+ retry_count=$((retry_count + 1))
1537
+ rm -f "$pr_response_file"
1538
+ continue
1539
+ else
1540
+ emit_error_event "github_pr_curl_failed" "curl command failed (exit $curl_exit) when creating PR" "exit"
1541
+ GITHUB_API_ERROR_TYPE="curl_error"
1542
+ GITHUB_API_ERROR_MESSAGE="curl exited with code $curl_exit"
1543
+ GITHUB_PR_EXIT=8
1544
+ rm -f "$pr_response_file"
1545
+ return 8
1546
+ fi
1547
+ fi
1548
+
1549
+ if [ "${KASEKI_DEBUG:-0}" = "1" ]; then
1550
+ printf 'Debug: PR API response HTTP status: %s (attempt %d)\n' "$pr_http_status" $((retry_count + 1)) | tee -a /results/git-push.log
1551
+ fi
1552
+
1553
+ # Validate the API response
1554
+ if validate_github_api_response "$pr_http_status" "$pr_response" /results/git-push.log; then
1555
+ # API returned success (201); now extract the URL
1556
+ pr_url="$(printf '%s' "$pr_response" | node -e "const d = JSON.parse(require('fs').readFileSync(0, 'utf8')); process.stdout.write(d.html_url || '')" 2>/dev/null || true)"
1557
+
1558
+ if [ -n "$pr_url" ]; then
1559
+ GITHUB_PR_URL="$pr_url"
1560
+ GITHUB_PR_EXIT=0
1561
+ printf 'Pull request created: %s\n' "$pr_url" | tee -a /results/git-push.log
1562
+ pr_created=1
1563
+ rm -f "$pr_response_file"
1564
+ break
1565
+ else
1566
+ # HTTP 201 but no html_url in response - malformed response
1567
+ printf 'Pull request API returned success (201) but response missing html_url field\n' | tee -a /results/git-push.log >&2
1568
+ emit_error_event "github_pr_response_malformed" "GitHub PR API returned 201 but response missing html_url field" "exit"
1569
+ if [ "${KASEKI_DEBUG:-0}" = "1" ]; then
1570
+ printf 'Debug: Full API response:\n%s\n' "$pr_response" | tee -a /results/git-push.log
1571
+ fi
1572
+ GITHUB_PR_EXIT=9
1573
+ pr_created=0
1574
+ rm -f "$pr_response_file"
1575
+ break
1576
+ fi
1577
+ else
1578
+ # API returned an error
1579
+ if is_github_pr_error_retryable "$pr_http_status" "$GITHUB_API_ERROR_TYPE"; then
1580
+ printf 'GitHub API returned retryable error (attempt %d): %s (HTTP %s)\n' $((retry_count + 1)) "$GITHUB_API_ERROR_TYPE" "$pr_http_status" | tee -a /results/git-push.log
1581
+ retry_count=$((retry_count + 1))
1582
+ rm -f "$pr_response_file"
1583
+ continue
1584
+ else
1585
+ # Permanent error, give up
1586
+ printf 'Failed to create PR. API error: %s\n' "$GITHUB_API_ERROR_MESSAGE" | tee -a /results/git-push.log >&2
1587
+ emit_error_event "github_pr_api_failed" "GitHub API error ($GITHUB_API_ERROR_TYPE): $GITHUB_API_ERROR_MESSAGE (HTTP $GITHUB_API_HTTP_STATUS)" "exit"
1588
+ if [ "${KASEKI_DEBUG:-0}" = "1" ]; then
1589
+ printf 'Debug: API error type: %s, HTTP status: %s\n' "$GITHUB_API_ERROR_TYPE" "$GITHUB_API_HTTP_STATUS" | tee -a /results/git-push.log
1590
+ printf 'Debug: Full response:\n%s\n' "$pr_response" | tee -a /results/git-push.log
1591
+ fi
1592
+ GITHUB_PR_EXIT=9
1593
+ pr_created=0
1594
+ rm -f "$pr_response_file"
1595
+ break
1596
+ fi
1597
+ fi
1598
+ done
1393
1599
 
1394
- if [ -n "$pr_url" ]; then
1395
- GITHUB_PR_URL="$pr_url"
1396
- GITHUB_PR_EXIT=0
1397
- printf 'Pull request created: %s\n' "$pr_url" | tee -a /results/git-push.log
1398
- else
1399
- printf 'Failed to create PR. Response: %s\n' "$pr_response" | tee -a /results/git-push.log >&2
1400
- GITHUB_PR_EXIT=9
1600
+ if [ $pr_created -eq 0 ] && [ $GITHUB_PR_EXIT -ne 0 ]; then
1601
+ return "$GITHUB_PR_EXIT"
1401
1602
  fi
1402
1603
 
1403
1604
  # Clean up token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanautomation/kaseki-agent",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Ephemeral coding-agent runner: orchestrates Pi CLI via Docker for automated code modifications with validation",
5
5
  "type": "module",
6
6
  "license": "MIT",